ENH - Apps

This commit is contained in:
Eichenholz
2026-08-03 16:46:19 +02:00
parent 6addba279d
commit fcf59da357
40 changed files with 1335 additions and 219 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

+712
View File
@@ -66,6 +66,7 @@ button {
}
.phone-screen {
position: relative;
container-type: size;
width: 100%;
height: 100%;
overflow: hidden;
@@ -207,11 +208,19 @@ button {
border-radius: 13px;
display: grid;
place-items: center;
overflow: hidden;
color: white;
box-shadow:
0 4px 13px #0004,
inset 0 1px 1px #ffffff55;
}
.app-icon > img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
pointer-events: none;
}
.app-icon-label {
width: 74px;
overflow: hidden;
@@ -246,6 +255,11 @@ button {
.app-icon--settings {
background: linear-gradient(145deg, #aeb3bd, #50535b);
}
.app-icon--image {
background: transparent;
box-shadow: none;
outline: none;
}
.app-dock {
position: absolute;
z-index: 8;
@@ -978,6 +992,704 @@ button {
.wallpaper-picker button.active {
border-color: #0a84ff;
}
/* Reference-accurate app surfaces. These remain scoped to the phone resource. */
.calculator-app {
padding: 54px 16px 27px;
}
.calculator-display {
height: 226px;
padding: 0 5px 4px;
font-size: 78px;
letter-spacing: -5px;
}
.calculator-pad {
gap: 11px 14px;
}
.calculator-pad button {
font-size: 28px;
font-weight: 400;
}
.reference-camera {
display: grid;
grid-template-rows: 83px minmax(0, 1fr) 190px;
padding: 0 0 24px;
background: #020202;
}
.camera-topbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: end;
padding: 45px 15px 11px;
}
.camera-topbar > div {
display: flex;
gap: 17px;
}
.camera-topbar > svg {
justify-self: end;
}
.camera-topbar button {
border: 0;
background: transparent;
color: inherit;
}
.camera-topbar .camera-chevron {
width: 34px;
height: 26px;
display: grid;
place-items: center;
border-radius: 20px;
background: #262629;
}
.reference-viewfinder {
position: relative;
min-height: 0;
overflow: hidden;
background:
radial-gradient(circle at 66% 21%, #ffd88baa, transparent 15%),
radial-gradient(circle at 45% 52%, #6d8ca5 0 6%, transparent 7%),
linear-gradient(150deg, #173542, #5b3e5b 56%, #13191c);
}
.reference-viewfinder::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
transparent 45%,
#ffffff0f 46%,
transparent 48%
);
}
.focus-corner {
position: absolute;
z-index: 2;
width: 24px;
height: 24px;
border-color: white;
}
.focus-corner--tl {
top: 1px;
left: 1px;
border-top: 1px solid;
border-left: 1px solid;
}
.focus-corner--tr {
top: 1px;
right: 1px;
border-top: 1px solid;
border-right: 1px solid;
}
.focus-corner--bl {
bottom: 1px;
left: 1px;
border-bottom: 1px solid;
border-left: 1px solid;
}
.focus-corner--br {
right: 1px;
bottom: 1px;
border-right: 1px solid;
border-bottom: 1px solid;
}
.reference-viewfinder .camera-zoom {
z-index: 3;
bottom: 13px;
}
.reference-viewfinder .camera-zoom button {
display: inline-flex;
align-items: center;
justify-content: center;
background: #0008;
font-weight: 700;
}
.reference-viewfinder .camera-zoom small {
margin-left: 1px;
font-size: 8px;
}
.reference-camera-footer {
display: grid;
grid-template-rows: 43px 1fr;
background: #020202;
}
.camera-mode-strip {
display: flex;
align-items: center;
gap: 21px;
padding: 0 148px;
overflow: hidden;
}
.camera-mode-strip button {
flex: none;
padding: 0;
border: 0;
background: transparent;
color: white;
font-size: 10px;
font-weight: 650;
text-transform: uppercase;
white-space: nowrap;
}
.camera-mode-strip button.active {
color: #ffd60a;
}
.camera-shutter-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
padding: 0 31px 12px;
}
.camera-thumbnail {
width: 44px;
height: 44px;
border-radius: 4px;
}
.reference-shutter {
justify-self: center;
width: 62px;
height: 62px;
display: grid;
place-items: center;
border: 3px solid white;
border-radius: 50%;
background: white;
color: #111;
box-shadow: inset 0 0 0 3px #020202;
}
.reference-flip {
justify-self: end;
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: #1c1c1e;
color: white;
}
.reference-tabbar {
position: absolute;
z-index: 6;
right: 0;
bottom: 21px;
left: 0;
height: 58px;
display: flex;
align-items: flex-start;
justify-content: space-around;
padding: 7px 5px 0;
border-top: 1px solid #ffffff18;
background: #101012dd;
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
}
.reference-tabbar button {
min-width: 49px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0;
border: 0;
background: transparent;
color: #77777c;
font-size: 9px;
}
.reference-tabbar button.active {
color: #0a84ff;
}
.clock-app .app-tabs button.active {
color: #d99900;
}
.clock-app {
padding-top: 0;
background: #020202;
}
.reference-clock-header {
padding: 47px 16px 0;
}
.reference-clock-header nav {
display: flex;
justify-content: space-between;
color: #d99900;
}
.reference-clock-header nav button {
padding: 0;
border: 0;
background: transparent;
color: inherit;
font-size: 15px;
}
.reference-clock-header h1 {
margin: 14px 0 0;
padding-bottom: 11px;
border-bottom: 1px solid #ffffff1c;
font-size: 35px;
line-height: 1;
}
.clock-app .clock-content {
height: calc(100% - 155px);
padding: 0 16px 72px;
}
.world-clock-list > div {
min-height: 95px;
display: flex;
align-items: center;
border-bottom: 1px solid #ffffff1c;
}
.world-clock-list > div > div {
flex: 1;
display: flex;
flex-direction: column;
}
.world-clock-list small {
color: #8e8e93;
font-size: 11px;
}
.world-clock-list strong {
font-size: min(25px, 7cqw);
font-weight: 400;
}
.world-clock-list time {
font-size: min(42px, 11cqw);
font-weight: 200;
letter-spacing: -2px;
}
.reference-clock-tabs button.active {
color: #d99900;
}
.reference-photos {
padding: 0 0 79px;
background: #020202;
}
.photos-library,
.photos-scroll {
height: 100%;
overflow-y: auto;
}
.photos-floating-header {
position: sticky;
z-index: 3;
top: 0;
height: 95px;
display: flex;
align-items: flex-end;
gap: 8px;
padding: 47px 17px 14px;
background: linear-gradient(#000 55%, transparent);
}
.photos-floating-header > div {
display: flex;
flex: 1;
flex-direction: column;
}
.photos-floating-header strong {
font-size: 17px;
}
.photos-floating-header span {
font-size: 12px;
font-weight: 600;
}
.photos-floating-header button {
height: 27px;
padding: 0 12px;
border: 0;
border-radius: 18px;
background: #ffffff1d;
color: white;
font-size: 11px;
font-weight: 650;
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
}
.photos-floating-header button:last-child {
width: 28px;
padding: 0;
border-radius: 50%;
}
.reference-photo-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 2px;
}
.reference-photo-grid article {
aspect-ratio: 1;
}
.photos-count {
padding: 15px;
text-align: center;
font-size: 11px;
}
.photos-period {
position: sticky;
bottom: 5px;
width: max-content;
margin: 0 auto;
display: flex;
padding: 3px;
border-radius: 22px;
background: #303033aa;
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.photos-period button {
padding: 5px 12px;
border: 0;
border-radius: 18px;
background: transparent;
color: #ddd;
font-size: 10px;
font-weight: 650;
}
.photos-period button.active {
background: #ffffff25;
color: white;
}
.photos-scroll {
padding: 82px 16px 20px;
}
.photos-scroll > h1 {
margin: 0 0 28px;
font-size: 34px;
}
.photos-section-title {
margin-top: 22px;
padding-top: 13px;
display: flex;
align-items: end;
border-top: 1px solid #ffffff1b;
}
.photos-section-title h2 {
flex: 1;
margin: 0;
font-size: 22px;
}
.photos-section-title button {
border: 0;
background: transparent;
color: #0a84ff;
}
.memory-card {
position: relative;
height: 410px;
margin-top: 12px;
padding: 16px;
display: flex;
flex-direction: column;
border-radius: 17px;
box-shadow: inset 0 -160px 100px #0008;
}
.memory-card > svg {
margin-left: auto;
}
.memory-card > div {
margin-top: auto;
display: flex;
flex-direction: column;
}
.memory-card strong {
font-size: 26px;
}
.memory-card span {
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
}
.featured-row {
display: flex;
gap: 9px;
margin-top: 12px;
overflow-x: auto;
}
.featured-row article {
width: 88%;
flex: none;
display: flex;
flex-direction: column;
}
.featured-row article > div {
aspect-ratio: 1;
border-radius: 10px;
}
.featured-row article span {
color: #777;
font-size: 12px;
}
.reference-photos .photo-grid {
height: auto;
margin-top: 13px;
padding-bottom: 10px;
}
.reference-store {
padding: 0 0 79px;
background: #0e0e0e;
}
.store-scroll {
height: 100%;
padding: 52px 19px 26px;
overflow-y: auto;
}
.store-title {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.store-title > div {
flex: 1;
display: flex;
align-items: baseline;
gap: 11px;
}
.store-title h1 {
margin: 0;
font-size: 34px;
}
.store-title strong {
color: #777;
font-size: 18px;
}
.store-title > span {
width: 37px;
height: 37px;
display: grid;
place-items: center;
border-radius: 50%;
background: linear-gradient(145deg, #3a3c46, #1b1c22);
}
.editorial-card {
position: relative;
height: 390px;
margin-bottom: 18px;
padding: 20px;
overflow: hidden;
border-radius: 22px;
box-shadow: 0 10px 28px #0007;
}
.editorial-card small {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.editorial-card h2 {
width: 80%;
margin: 7px 0;
font-size: 28px;
line-height: 1.05;
}
.editorial-card p {
width: 80%;
margin: 0;
color: #ffffffbf;
font-size: 14px;
}
.editorial-card--sky {
background:
radial-gradient(
circle at 68% 63%,
#fff 0 4%,
#4de1fb 5% 9%,
transparent 10%
),
radial-gradient(circle at 65% 60%, #c653ff99, transparent 31%),
linear-gradient(145deg, #10144d, #4630a6 58%, #1bc7e2);
}
.editorial-orbit {
position: absolute;
right: -10px;
bottom: 30px;
width: 230px;
height: 230px;
display: grid;
place-items: center;
border: 2px solid #ffffff99;
border-radius: 50%;
font-size: 74px;
transform: rotate(-18deg);
}
.editorial-card--music {
background:
radial-gradient(circle at 60% 70%, #ffdd45, transparent 22%),
linear-gradient(155deg, #531849, #ca2c72 60%, #f48c46);
}
.editorial-wave {
position: absolute;
right: 16px;
bottom: 28px;
left: 16px;
height: 110px;
background: repeating-linear-gradient(
90deg,
#ffffffaa 0 4px,
transparent 4px 12px
);
clip-path: polygon(
0 45%,
10% 10%,
20% 82%,
30% 25%,
40% 70%,
50% 5%,
60% 85%,
70% 28%,
80% 68%,
90% 13%,
100% 55%,
100% 70%,
0 70%
);
}
.editorial-card--play {
height: 330px;
background:
radial-gradient(circle at 80% 70%, #64ffbe88, transparent 30%),
linear-gradient(145deg, #0d4137, #18236c);
}
.store-section-title {
padding: 14px 0 12px;
border-top: 1px solid #ffffff1c;
}
.store-section-title h2 {
margin: 0;
font-size: 19px;
}
.store-section-title p {
margin: 3px 0 0;
color: #777;
font-size: 13px;
}
.reference-store .store-feature {
margin: 0 0 20px;
}
.reference-store .store-list {
padding: 0;
}
.reference-settings {
padding: 0;
background: #0e0e0e;
}
.settings-scroll {
height: 100%;
padding: 69px 16px 38px;
overflow-y: auto;
}
.settings-scroll > h1 {
margin: 0 0 17px;
font-size: 35px;
line-height: 1;
}
.settings-search {
height: 36px;
display: flex;
align-items: center;
gap: 7px;
padding: 0 9px;
border-radius: 9px;
background: #1c1c1e;
color: #777;
}
.settings-search input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: white;
font-size: 16px;
}
.settings-account {
margin: 17px 0;
padding: 10px;
display: flex;
align-items: center;
gap: 12px;
border-radius: 12px;
background: #1c1c1e;
}
.settings-account > span {
width: 58px;
height: 58px;
display: grid;
place-items: center;
border-radius: 50%;
background: linear-gradient(145deg, #596172, #22252c);
}
.settings-account > div {
flex: 1;
display: flex;
flex-direction: column;
}
.settings-account strong {
font-size: 17px;
}
.settings-account small {
color: #ccc;
font-size: 11px;
}
.reference-settings-group {
margin-bottom: 17px;
padding: 0 11px;
overflow: hidden;
border-radius: 12px;
background: #1c1c1e;
}
.reference-settings-group article {
min-height: 44px;
display: flex;
align-items: center;
gap: 12px;
}
.reference-settings-group article:not(:last-child) > div {
border-bottom: 1px solid #ffffff18;
}
.reference-settings-group article > div {
min-width: 0;
flex: 1;
align-self: stretch;
display: flex;
align-items: center;
justify-content: space-between;
}
.reference-settings-group article > div strong {
font-size: 14px;
font-weight: 400;
}
.reference-settings-group article > div small {
max-width: 100px;
overflow: hidden;
color: #999;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.reference-settings-group .settings-symbol {
width: 27px;
height: 27px;
flex: none;
border-radius: 6px;
}
.settings-chevron {
color: #555;
}
.reference-settings .wallpaper-picker button {
height: 105px;
}
* {
scrollbar-width: none;
}
*::-webkit-scrollbar {
display: none;
}
.camera-mode-strip {
padding: 0;
}
.camera-mode-strip button:first-child {
margin-left: -205px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
+20 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { usePhoneStore } from '@/stores/phone'
@@ -18,6 +19,7 @@ const props = withDefaults(
const phone = usePhoneStore()
const router = useRouter()
const iconFailed = ref(false)
function launch(event: MouseEvent): void {
const button = event.currentTarget as HTMLElement
@@ -49,8 +51,24 @@ function launch(event: MouseEvent): void {
:aria-label="phone.t(app.labelKey)"
@click="launch"
>
<span class="app-icon" :class="app.iconClass" aria-hidden="true">
<component :is="app.icon" :size="compact ? 18 : 28" :stroke-width="2" />
<span
class="app-icon"
:class="[app.iconClass, { 'app-icon--image': !iconFailed }]"
aria-hidden="true"
>
<img
v-if="!iconFailed"
:src="app.iconImage"
alt=""
draggable="false"
@error="iconFailed = true"
/>
<component
:is="app.icon"
v-else
:size="compact ? 18 : 28"
:stroke-width="2"
/>
</span>
<span v-if="showLabel" class="app-icon-label">{{
phone.t(app.labelKey)
+3
View File
@@ -8,6 +8,9 @@ describe('app registry', () => {
expect(PHONE_APPS.every((app) => app.route === `/apps/${app.id}`)).toBe(
true,
)
expect(PHONE_APPS.every((app) => app.iconImage.endsWith('.webp'))).toBe(
true,
)
expect(
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
+12
View File
@@ -8,6 +8,12 @@ import {
} from 'lucide-vue-next'
import { defineAsyncComponent, markRaw } from 'vue'
import appStoreIcon from '@/assets/img/app-icons/apps.webp'
import calculatorIcon from '@/assets/img/app-icons/calculator.webp'
import cameraIcon from '@/assets/img/app-icons/camera.webp'
import clockIcon from '@/assets/img/app-icons/clock.webp'
import photosIcon from '@/assets/img/app-icons/gallery.webp'
import settingsIcon from '@/assets/img/app-icons/settings.webp'
import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
export const PHONE_APPS: PhoneAppDefinition[] = [
@@ -19,6 +25,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
gridOrder: 0,
icon: markRaw(Calculator),
iconClass: 'app-icon--calculator',
iconImage: calculatorIcon,
id: 'calculator',
labelKey: 'Apps.calculator.name',
route: '/apps/calculator',
@@ -31,6 +38,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
gridOrder: 1,
icon: markRaw(Camera),
iconClass: 'app-icon--camera',
iconImage: cameraIcon,
id: 'camera',
labelKey: 'Apps.camera.name',
route: '/apps/camera',
@@ -43,6 +51,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
gridOrder: 2,
icon: markRaw(Clock3),
iconClass: 'app-icon--clock',
iconImage: clockIcon,
id: 'clock',
labelKey: 'Apps.clock.name',
route: '/apps/clock',
@@ -55,6 +64,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
gridOrder: 3,
icon: markRaw(Images),
iconClass: 'app-icon--photos',
iconImage: photosIcon,
id: 'photos',
labelKey: 'Apps.photos.name',
route: '/apps/photos',
@@ -67,6 +77,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
gridOrder: 4,
icon: markRaw(ShoppingBag),
iconClass: 'app-icon--store',
iconImage: appStoreIcon,
id: 'app-store',
labelKey: 'Apps.appStore.name',
route: '/apps/app-store',
@@ -79,6 +90,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
gridOrder: 5,
icon: markRaw(Settings),
iconClass: 'app-icon--settings',
iconImage: settingsIcon,
id: 'settings',
labelKey: 'Apps.settings.name',
route: '/apps/settings',
+62 -1
View File
@@ -26,6 +26,11 @@ const defaultLocales: LocaleTree = {
get: 'GET',
open: 'OPEN',
searchPlaceholder: 'Games, Apps, Stories and More',
communityTitle: 'Black Voices and Creators',
communityBody: 'Apps and games from the community',
playing: "What We're Playing",
recommended: 'Recommended for You',
selected: 'Great apps selected by our editors',
tabs: {
today: 'Today',
apps: 'Apps',
@@ -39,18 +44,42 @@ const defaultLocales: LocaleTree = {
trail: 'Explore nearby',
prism: 'A colorful puzzle',
},
card: {
oneEyebrow: 'App of the Day',
oneTitle: 'A universe in your pocket',
oneBody: 'Explore something extraordinary today.',
twoEyebrow: 'Now Trending',
twoTitle: 'Turn up your afternoon',
twoBody: 'Fresh sounds and stories picked for you.',
threeEyebrow: "Editors' Choice",
threeTitle: 'Play without limits',
threeBody: 'A new world is waiting.',
},
},
calculator: { name: 'Calculator' },
camera: {
name: 'Camera',
shutter: 'Take photo',
flip: 'Flip camera',
modes: { video: 'Video', photo: 'Photo', portrait: 'Portrait' },
flash: 'Toggle flash',
controls: 'Camera controls',
modes: {
timelapse: 'Timelapse',
slowMo: 'Slow-Mo',
cinematic: 'Cinematic',
video: 'Video',
photo: 'Photo',
portrait: 'Portrait',
pano: 'Pano',
},
},
clock: {
name: 'Clock',
lap: 'Lap',
minutes: 'Minutes',
add: 'Add clock',
today: 'Today',
offset: '+0HRS',
tabs: {
world: 'World Clock',
alarm: 'Alarm',
@@ -73,6 +102,19 @@ const defaultLocales: LocaleTree = {
items: 'items',
memories: 'Memories',
featured: 'City colors',
dateRange: '19 Apr7 May 2024',
place: 'Los Santos & more',
select: 'Select',
count: '3,042 Photos, 125 Videos',
years: 'Years',
months: 'Months',
days: 'Days',
allPhotos: 'All Photos',
seeAll: 'See All',
onThisDay: 'On This Day',
trip: 'MAR 2024 TRIP',
featuredPhotos: 'Featured Photos',
featuredDate: '30 Mar 2024',
tabs: {
library: 'Library',
forYou: 'For You',
@@ -94,6 +136,24 @@ const defaultLocales: LocaleTree = {
bluetooth: 'Bluetooth',
airplaneMode: 'Airplane Mode',
wallpaper: 'Wallpaper',
on: 'On',
accountName: 'Sky Citizen',
accountDetail: 'Apple ID, iCloud, Media & Purchases',
mobileServices: 'Mobile Services',
personalHotspot: 'Personal Hotspot',
vpn: 'VPN',
notifications: 'Notifications',
sounds: 'Sounds & Haptics',
focus: 'Focus',
screenTime: 'Screen Time',
general: 'General',
display: 'Display & Brightness',
homeScreen: 'Home Screen & App Library',
accessibility: 'Accessibility',
battery: 'Battery',
privacy: 'Privacy & Security',
passwords: 'Passwords',
wallpaperPicker: 'Built-in Wallpapers',
toggle: {
wifi: 'Toggle Wi-Fi',
bluetooth: 'Toggle Bluetooth',
@@ -109,6 +169,7 @@ const defaultLocales: LocaleTree = {
Common: {
cancel: 'Cancel',
close: 'Close',
edit: 'Edit',
home: 'Home',
pause: 'Pause',
phone: 'Phone',
+1
View File
@@ -23,6 +23,7 @@ export type PhoneAppDefinition = {
gridOrder: number
icon: Component
iconClass: string
iconImage: string
id: PhoneAppId
labelKey: string
route: `/apps/${PhoneAppId}`
+110 -46
View File
@@ -1,37 +1,52 @@
<script setup lang="ts">
import {
Gamepad2,
Grid2X2,
Rocket,
Search,
Sparkles,
UserRound,
} from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { Search } from 'lucide-vue-next'
import { useMediaStore } from '@/stores/media'
import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
const media = useMediaStore()
const tab = ref('today')
const tab = ref<'today' | 'apps' | 'games' | 'arcade' | 'search'>('today')
const query = ref('')
const tabs = ['today', 'apps', 'games', 'arcade', 'search']
const tabs = [
{ id: 'today', icon: Sparkles },
{ id: 'apps', icon: Grid2X2 },
{ id: 'games', icon: Gamepad2 },
{ id: 'arcade', icon: Rocket },
{ id: 'search', icon: Search },
] as const
const catalog = [
{
id: 'orbit',
title: 'Orbit',
subtitle: 'Apps.appStore.catalog.orbit',
gradient: 'linear-gradient(135deg,#6f5cff,#29d3c2)',
gradient: 'linear-gradient(145deg,#453bd1,#75e8ff)',
},
{
id: 'studio',
title: 'Studio',
subtitle: 'Apps.appStore.catalog.studio',
gradient: 'linear-gradient(135deg,#ff8b4a,#ff2d75)',
gradient: 'linear-gradient(145deg,#ff8b4a,#ff2d75)',
},
{
id: 'trail',
title: 'Trail',
subtitle: 'Apps.appStore.catalog.trail',
gradient: 'linear-gradient(135deg,#4bd37b,#147ec1)',
gradient: 'linear-gradient(145deg,#4bd37b,#147ec1)',
},
{
id: 'puzzle',
id: 'prism',
title: 'Prism',
subtitle: 'Apps.appStore.catalog.prism',
gradient: 'linear-gradient(135deg,#ffd84d,#7b36dc)',
gradient: 'linear-gradient(145deg,#ffd84d,#7b36dc)',
},
]
const shown = computed(() =>
@@ -39,53 +54,102 @@ const shown = computed(() =>
item.title.toLowerCase().includes(query.value.toLowerCase()),
),
)
const date = new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
month: 'long',
}).format(new Date())
</script>
<template>
<main class="native-app store-app">
<header class="app-header">
<small>{{ phone.t('Apps.appStore.eyebrow') }}</small>
<h1>{{ phone.t(`Apps.appStore.tabs.${tab}`) }}</h1>
</header>
<div v-if="tab === 'search'" class="app-search">
<Search :size="17" /><input
v-model="query"
:placeholder="phone.t('Apps.appStore.searchPlaceholder')"
/>
</div>
<section class="store-feature">
<p>{{ phone.t('Apps.appStore.featured') }}</p>
<h2>{{ phone.t('Apps.appStore.heroTitle') }}</h2>
<span>{{ phone.t('Apps.appStore.heroBody') }}</span>
</section>
<section class="store-list">
<article v-for="item in shown" :key="item.id">
<div class="store-icon" :style="{ background: item.gradient }">
{{ item.title[0] }}
</div>
<main class="native-app reference-store">
<section class="store-scroll">
<header class="store-title">
<div>
<strong>{{ item.title }}</strong
><small>{{ phone.t(item.subtitle) }}</small>
<h1>{{ phone.t(`Apps.appStore.tabs.${tab}`) }}</h1>
<strong v-if="tab === 'today'">{{ date }}</strong>
</div>
<button type="button" @click="media.claimApp(item.id)">
{{
phone.t(
media.claimedApps.includes(item.id)
? 'Apps.appStore.open'
: 'Apps.appStore.get',
)
}}
</button>
</article>
<span><UserRound :size="22" /></span>
</header>
<div v-if="tab === 'search'" class="app-search">
<Search :size="17" /><input
v-model="query"
:placeholder="phone.t('Apps.appStore.searchPlaceholder')"
/>
</div>
<template v-if="tab === 'today'">
<article class="editorial-card editorial-card--sky">
<small>{{ phone.t('Apps.appStore.card.oneEyebrow') }}</small>
<h2>{{ phone.t('Apps.appStore.card.oneTitle') }}</h2>
<p>{{ phone.t('Apps.appStore.card.oneBody') }}</p>
<div class="editorial-orbit"></div>
</article>
<article class="editorial-card editorial-card--music">
<small>{{ phone.t('Apps.appStore.card.twoEyebrow') }}</small>
<h2>{{ phone.t('Apps.appStore.card.twoTitle') }}</h2>
<p>{{ phone.t('Apps.appStore.card.twoBody') }}</p>
<div class="editorial-wave" />
</article>
<div class="store-section-title">
<h2>{{ phone.t('Apps.appStore.communityTitle') }}</h2>
<p>{{ phone.t('Apps.appStore.communityBody') }}</p>
</div>
<article class="editorial-card editorial-card--play">
<small>{{ phone.t('Apps.appStore.card.threeEyebrow') }}</small>
<h2>{{ phone.t('Apps.appStore.card.threeTitle') }}</h2>
<p>{{ phone.t('Apps.appStore.card.threeBody') }}</p>
</article>
</template>
<template v-else>
<article v-if="tab !== 'search'" class="store-feature">
<p>{{ phone.t('Apps.appStore.featured') }}</p>
<h2>{{ phone.t('Apps.appStore.heroTitle') }}</h2>
<span>{{ phone.t('Apps.appStore.heroBody') }}</span>
</article>
<div class="store-section-title">
<h2>
{{
phone.t(
tab === 'games'
? 'Apps.appStore.playing'
: 'Apps.appStore.recommended',
)
}}
</h2>
<p>{{ phone.t('Apps.appStore.selected') }}</p>
</div>
<section class="store-list">
<article v-for="item in shown" :key="item.id">
<div class="store-icon" :style="{ background: item.gradient }">
{{ item.title[0] }}
</div>
<div>
<strong>{{ item.title }}</strong
><small>{{ phone.t(item.subtitle) }}</small>
</div>
<button type="button" @click="media.claimApp(item.id)">
{{
phone.t(
media.claimedApps.includes(item.id)
? 'Apps.appStore.open'
: 'Apps.appStore.get',
)
}}
</button>
</article>
</section>
</template>
</section>
<nav class="app-tabs">
<nav class="reference-tabbar">
<button
v-for="item in tabs"
:key="item"
:class="{ active: tab === item }"
:key="item.id"
:class="{ active: tab === item.id }"
type="button"
@click="tab = item"
@click="tab = item.id"
>
{{ phone.t(`Apps.appStore.tabs.${item}`) }}
<component :is="item.icon" :size="21" /><span>{{
phone.t(`Apps.appStore.tabs.${item.id}`)
}}</span>
</button>
</nav>
</main>
+99 -41
View File
@@ -1,68 +1,126 @@
<script setup lang="ts">
import { Camera, RotateCcw } from 'lucide-vue-next'
import {
Aperture,
Camera,
ChevronUp,
RotateCcw,
Zap,
ZapOff,
} from 'lucide-vue-next'
import { ref } from 'vue'
import { useMediaStore } from '@/stores/media'
import { usePhoneStore } from '@/stores/phone'
const media = useMediaStore()
const phone = usePhoneStore()
const flash = ref(false)
const modes = ['video', 'photo', 'portrait'] as const
const flashEnabled = ref(false)
const modes = [
'timelapse',
'slowMo',
'cinematic',
'video',
'photo',
'portrait',
'pano',
] as const
function capture(): void {
flash.value = true
media.capture()
setTimeout(() => {
flash.value = false
}, 120)
window.setTimeout(() => (flash.value = false), 120)
}
</script>
<template>
<main class="native-app camera-app">
<div
class="camera-viewfinder"
<main class="native-app reference-camera">
<header class="camera-topbar">
<div>
<button
type="button"
:aria-label="phone.t('Apps.camera.flash')"
@click="flashEnabled = !flashEnabled"
>
<Zap v-if="flashEnabled" :size="22" />
<ZapOff v-else :size="22" />
</button>
<Aperture :size="22" />
</div>
<button
class="camera-chevron"
type="button"
:aria-label="phone.t('Apps.camera.controls')"
>
<ChevronUp :size="20" />
</button>
<Aperture :size="22" />
</header>
<section
class="reference-viewfinder"
:class="`camera-facing--${media.cameraFacing}`"
>
<div class="camera-flash" :class="{ active: flash }" />
<div class="camera-grid" />
<i
v-for="corner in ['tl', 'tr', 'bl', 'br']"
:key="corner"
:class="`focus-corner focus-corner--${corner}`"
/>
<div class="camera-zoom">
<button
v-for="zoom in [0.5, 1, 2]"
v-for="zoom in [0.5, 1]"
:key="zoom"
:class="{ active: media.cameraZoom === zoom }"
type="button"
@click="media.cameraZoom = zoom"
>
{{ zoom }}×
{{ zoom }}<small v-if="zoom === 1">x</small>
</button>
</div>
</div>
<div class="camera-modes">
<button
v-for="mode in modes"
:key="mode"
:class="{ active: media.cameraMode === mode }"
type="button"
@click="media.cameraMode = mode"
>
{{ phone.t(`Apps.camera.modes.${mode}`) }}
</button>
</div>
<div class="camera-controls">
<span /><button
class="shutter"
type="button"
:aria-label="phone.t('Apps.camera.shutter')"
@click="capture"
>
<Camera :size="26" /></button
><button
type="button"
:aria-label="phone.t('Apps.camera.flip')"
@click="
media.cameraFacing = media.cameraFacing === 'rear' ? 'front' : 'rear'
"
>
<RotateCcw :size="23" />
</button>
</div>
</section>
<footer class="reference-camera-footer">
<nav class="camera-mode-strip">
<button
v-for="mode in modes"
:key="mode"
:class="{ active: media.cameraMode === mode }"
type="button"
@click="
mode === 'photo' || mode === 'portrait' || mode === 'video'
? (media.cameraMode = mode)
: undefined
"
>
{{ phone.t(`Apps.camera.modes.${mode}`) }}
</button>
</nav>
<div class="camera-shutter-row">
<div
class="camera-thumbnail"
:style="{ background: media.photos[0]?.gradient }"
/>
<button
class="reference-shutter"
type="button"
:aria-label="phone.t('Apps.camera.shutter')"
@click="capture"
>
<Camera :size="23" />
</button>
<button
class="reference-flip"
type="button"
:aria-label="phone.t('Apps.camera.flip')"
@click="
media.cameraFacing =
media.cameraFacing === 'rear' ? 'front' : 'rear'
"
>
<RotateCcw :size="22" />
</button>
</div>
</footer>
</main>
</template>
+30 -11
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { AlarmClock, Clock3, Plus, Timer, TimerReset } from 'lucide-vue-next'
import { useClockStore } from '@/stores/clock'
import { usePhoneStore } from '@/stores/phone'
@@ -35,7 +36,12 @@ const cities = [
{ key: 'london', zone: 'Europe/London' },
{ key: 'tokyo', zone: 'Asia/Tokyo' },
]
const tabs = ['world', 'alarm', 'stopwatch', 'timer'] as const
const tabs = [
{ id: 'world', icon: Clock3 },
{ id: 'alarm', icon: AlarmClock },
{ id: 'stopwatch', icon: Timer },
{ id: 'timer', icon: TimerReset },
] as const
function cityTime(zone: string): string {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
@@ -53,14 +59,25 @@ onBeforeUnmount(() => clearInterval(ticker))
<template>
<main class="native-app clock-app">
<header class="app-header">
<header class="reference-clock-header">
<nav>
<button type="button">{{ phone.t('Common.edit') }}</button
><button type="button" :aria-label="phone.t('Apps.clock.add')">
<Plus :size="25" />
</button>
</nav>
<h1>{{ phone.t(`Apps.clock.tabs.${tab}`) }}</h1>
</header>
<section class="clock-content">
<div v-if="tab === 'world'" class="settings-list">
<div v-for="city in cities" :key="city.key" class="settings-row">
<span>{{ phone.t(`Apps.clock.cities.${city.key}`) }}</span
><strong>{{ cityTime(city.zone) }}</strong>
<div v-if="tab === 'world'" class="world-clock-list">
<div v-for="city in cities" :key="city.key">
<div>
<small
>{{ phone.t('Apps.clock.today') }},
{{ phone.t('Apps.clock.offset') }}</small
><strong>{{ phone.t(`Apps.clock.cities.${city.key}`) }}</strong>
</div>
<time>{{ cityTime(city.zone) }}</time>
</div>
</div>
<div v-else-if="tab === 'alarm'" class="settings-list">
@@ -162,15 +179,17 @@ onBeforeUnmount(() => clearInterval(ticker))
</div>
</div>
</section>
<nav class="app-tabs">
<nav class="reference-tabbar reference-clock-tabs">
<button
v-for="item in tabs"
:key="item"
:class="{ active: tab === item }"
:key="item.id"
:class="{ active: tab === item.id }"
type="button"
@click="tab = item"
@click="tab = item.id"
>
{{ phone.t(`Apps.clock.tabs.${item}`) }}
<component :is="item.icon" :size="22" /><span>{{
phone.t(`Apps.clock.tabs.${item.id}`)
}}</span>
</button>
</nav>
</main>
+109 -46
View File
@@ -1,71 +1,134 @@
<script setup lang="ts">
import { Heart, Images, Library, Search } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { Search } from 'lucide-vue-next'
import { useMediaStore } from '@/stores/media'
import { usePhoneStore } from '@/stores/phone'
const media = useMediaStore()
const phone = usePhoneStore()
const tab = ref('library')
const tab = ref<'library' | 'forYou' | 'albums' | 'search'>('library')
const query = ref('')
const tabs = ['library', 'forYou', 'albums', 'search']
const photos = computed(() =>
const tabs = [
{ id: 'library', icon: Library },
{ id: 'forYou', icon: Heart },
{ id: 'albums', icon: Images },
{ id: 'search', icon: Search },
] as const
const gallery = computed(() =>
Array.from(
{ length: 54 },
(_, index) => media.photos[index % media.photos.length],
),
)
const filtered = computed(() =>
media.photos.filter((photo) =>
phone.t(photo.titleKey).toLowerCase().includes(query.value.toLowerCase()),
),
)
</script>
<template>
<main class="native-app photos-app">
<header class="app-header">
<h1>{{ phone.t(`Apps.photos.tabs.${tab}`) }}</h1>
</header>
<div v-if="tab === 'search'" class="app-search">
<Search :size="17" /><input
v-model="query"
:placeholder="phone.t('Apps.photos.searchPlaceholder')"
/>
</div>
<section v-if="tab === 'albums'" class="album-cards">
<article>
<div
class="photo-tile"
:style="{ background: media.photos[0]?.gradient }"
<main class="native-app reference-photos">
<section v-if="tab === 'library'" class="photos-library">
<header class="photos-floating-header">
<div>
<strong>{{ phone.t('Apps.photos.dateRange') }}</strong
><span>{{ phone.t('Apps.photos.place') }}</span>
</div>
<button type="button">{{ phone.t('Apps.photos.select') }}</button
><button type="button"></button>
</header>
<div class="reference-photo-grid">
<article
v-for="(photo, index) in gallery"
:key="`${photo.id}-${index}`"
:style="{ background: photo.gradient }"
/>
<strong>{{ phone.t('Apps.photos.recents') }}</strong
><small
>{{ media.photos.length }} {{ phone.t('Apps.photos.items') }}</small
>
</article>
<article>
<div class="photo-tile favorites" />
<strong>{{ phone.t('Apps.photos.favorites') }}</strong
><small>2 {{ phone.t('Apps.photos.items') }}</small>
</article>
</section>
<section v-else-if="tab === 'forYou'" class="featured-photo">
<p>{{ phone.t('Apps.photos.memories') }}</p>
<div :style="{ background: media.photos[1]?.gradient }">
<span>{{ phone.t('Apps.photos.featured') }}</span>
</div>
<p class="photos-count">{{ phone.t('Apps.photos.count') }}</p>
<div class="photos-period">
<button>{{ phone.t('Apps.photos.years') }}</button
><button>{{ phone.t('Apps.photos.months') }}</button
><button>{{ phone.t('Apps.photos.days') }}</button
><button class="active">{{ phone.t('Apps.photos.allPhotos') }}</button>
</div>
</section>
<section v-else class="photo-grid">
<section v-else-if="tab === 'forYou'" class="photos-scroll">
<h1>{{ phone.t('Apps.photos.tabs.forYou') }}</h1>
<div class="photos-section-title">
<h2>{{ phone.t('Apps.photos.memories') }}</h2>
<button>{{ phone.t('Apps.photos.seeAll') }}</button>
</div>
<article
v-for="photo in photos"
:key="photo.id"
class="photo-tile"
:style="{ background: photo.gradient }"
:aria-label="phone.t(photo.titleKey)"
/>
class="memory-card"
:style="{ background: media.photos[1]?.gradient }"
>
<Heart :size="25" />
<div>
<strong>{{ phone.t('Apps.photos.onThisDay') }}</strong
><span>{{ phone.t('Apps.photos.trip') }}</span>
</div>
</article>
<div class="photos-section-title">
<h2>{{ phone.t('Apps.photos.featuredPhotos') }}</h2>
</div>
<div class="featured-row">
<article v-for="photo in media.photos.slice(0, 2)" :key="photo.id">
<div :style="{ background: photo.gradient }" />
<strong>{{ phone.t(photo.titleKey) }}</strong
><span>{{ phone.t('Apps.photos.featuredDate') }}</span>
</article>
</div>
</section>
<nav class="app-tabs">
<section v-else-if="tab === 'albums'" class="photos-scroll">
<h1>{{ phone.t('Apps.photos.tabs.albums') }}</h1>
<div class="album-cards">
<article>
<div
class="photo-tile"
:style="{ background: media.photos[0]?.gradient }"
/>
<strong>{{ phone.t('Apps.photos.recents') }}</strong
><small
>{{ media.photos.length }} {{ phone.t('Apps.photos.items') }}</small
>
</article>
<article>
<div class="photo-tile favorites" />
<strong>{{ phone.t('Apps.photos.favorites') }}</strong
><small>2 {{ phone.t('Apps.photos.items') }}</small>
</article>
</div>
</section>
<section v-else class="photos-scroll">
<h1>{{ phone.t('Apps.photos.tabs.search') }}</h1>
<div class="app-search">
<Search :size="17" /><input
v-model="query"
:placeholder="phone.t('Apps.photos.searchPlaceholder')"
/>
</div>
<div class="photo-grid">
<article
v-for="photo in filtered"
:key="photo.id"
class="photo-tile"
:style="{ background: photo.gradient }"
/>
</div>
</section>
<nav class="reference-tabbar">
<button
v-for="item in tabs"
:key="item"
:class="{ active: tab === item }"
:key="item.id"
:class="{ active: tab === item.id }"
type="button"
@click="tab = item"
@click="tab = item.id"
>
{{ phone.t(`Apps.photos.tabs.${item}`) }}
<component :is="item.icon" :size="22" /><span>{{
phone.t(`Apps.photos.tabs.${item.id}`)
}}</span>
</button>
</nav>
</main>
+144 -54
View File
@@ -1,68 +1,158 @@
<script setup lang="ts">
import {
Accessibility,
BatteryMedium,
BellRing,
Bluetooth,
ChevronRight,
Clock3,
Focus,
Globe2,
Grid2X2,
KeyRound,
LockKeyhole,
Monitor,
Plane,
Radio,
Search,
Settings,
Smartphone,
Sun,
UserRound,
Volume2,
Wifi,
} from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { Bluetooth, Search, Wifi, Radio } from 'lucide-vue-next'
import { usePhoneStore } from '@/stores/phone'
import { WALLPAPER_IDS } from '@/utils/preferences'
const phone = usePhoneStore()
const query = ref('')
const rows = [
{ key: 'wifi', icon: Wifi, color: '#1677ff' },
{ key: 'bluetooth', icon: Bluetooth, color: '#1677ff' },
{ key: 'airplaneMode', icon: Radio, color: '#ff9500' },
] as const
const shown = computed(() =>
rows.filter((row) =>
phone
.t(`Apps.settings.${row.key}`)
.toLowerCase()
.includes(query.value.toLowerCase()),
),
const groups = [
[
{ key: 'airplaneMode', icon: Plane, color: '#ff9500', toggle: true },
{
key: 'wifi',
icon: Wifi,
color: '#0a84ff',
value: 'SkyNet 5G',
toggle: true,
},
{
key: 'bluetooth',
icon: Bluetooth,
color: '#0a84ff',
valueKey: 'Apps.settings.on',
toggle: true,
},
{ key: 'mobileServices', icon: Radio, color: '#30d158' },
{ key: 'personalHotspot', icon: Smartphone, color: '#30d158' },
{ key: 'vpn', icon: Globe2, color: '#0a84ff' },
],
[
{ key: 'notifications', icon: BellRing, color: '#ff453a' },
{ key: 'sounds', icon: Volume2, color: '#ff2d55' },
{ key: 'focus', icon: Focus, color: '#5e5ce6' },
{ key: 'screenTime', icon: Clock3, color: '#5e5ce6' },
],
[
{ key: 'general', icon: Settings, color: '#8e8e93' },
{ key: 'display', icon: Sun, color: '#0a84ff' },
{ key: 'homeScreen', icon: Grid2X2, color: '#5e5ce6' },
{ key: 'accessibility', icon: Accessibility, color: '#0a84ff' },
{ key: 'wallpaper', icon: Monitor, color: '#64d2ff' },
{ key: 'battery', icon: BatteryMedium, color: '#30d158' },
{ key: 'privacy', icon: LockKeyhole, color: '#0a84ff' },
],
[{ key: 'passwords', icon: KeyRound, color: '#636366' }],
]
const filteredGroups = computed(() =>
groups
.map((group) =>
group.filter((row) =>
phone
.t(`Apps.settings.${row.key}`)
.toLowerCase()
.includes(query.value.toLowerCase()),
),
)
.filter((group) => group.length),
)
</script>
<template>
<main class="native-app settings-app">
<header class="app-header">
<main class="native-app reference-settings">
<section class="settings-scroll">
<h1>{{ phone.t('Apps.settings.name') }}</h1>
</header>
<div class="app-search">
<Search :size="17" /><input
v-model="query"
:placeholder="phone.t('Apps.settings.searchPlaceholder')"
/>
</div>
<section class="settings-list">
<div v-for="row in shown" :key="row.key" class="settings-row">
<span class="settings-symbol" :style="{ background: row.color }"
><component :is="row.icon" :size="16" /></span
><span>{{ phone.t(`Apps.settings.${row.key}`) }}</span
><button
class="ios-switch"
:class="{ active: phone.preferences.settings[row.key] }"
type="button"
:aria-label="phone.t(`Apps.settings.toggle.${row.key}`)"
@click="
phone.setSetting(row.key, !phone.preferences.settings[row.key])
"
>
<span />
</button>
<div class="settings-search">
<Search :size="17" /><input
v-model="query"
:placeholder="phone.t('Apps.settings.searchPlaceholder')"
/>
</div>
</section>
<h2 class="settings-section-title">
{{ phone.t('Apps.settings.wallpaper') }}
</h2>
<section class="wallpaper-picker">
<button
v-for="wallpaper in WALLPAPER_IDS"
:key="wallpaper"
type="button"
:class="[
`wallpaper--${wallpaper}`,
{ active: phone.preferences.settings.wallpaper === wallpaper },
]"
:aria-label="phone.t(`Apps.settings.wallpapers.${wallpaper}`)"
@click="phone.setWallpaper(wallpaper)"
/>
<article class="settings-account">
<span><UserRound :size="34" /></span>
<div>
<strong>{{ phone.t('Apps.settings.accountName') }}</strong
><small>{{ phone.t('Apps.settings.accountDetail') }}</small>
</div>
<ChevronRight :size="19" />
</article>
<section
v-for="(group, groupIndex) in filteredGroups"
:key="groupIndex"
class="reference-settings-group"
>
<article v-for="row in group" :key="row.key">
<span class="settings-symbol" :style="{ background: row.color }"
><component :is="row.icon" :size="16"
/></span>
<div>
<strong>{{ phone.t(`Apps.settings.${row.key}`) }}</strong
><small v-if="row.value">{{ row.value }}</small
><small v-else-if="row.valueKey">{{ phone.t(row.valueKey) }}</small>
</div>
<button
v-if="row.toggle"
class="ios-switch"
:class="{
active:
phone.preferences.settings[
row.key as 'airplaneMode' | 'wifi' | 'bluetooth'
],
}"
type="button"
:aria-label="phone.t(`Apps.settings.toggle.${row.key}`)"
@click="
phone.setSetting(
row.key as 'airplaneMode' | 'wifi' | 'bluetooth',
!phone.preferences.settings[
row.key as 'airplaneMode' | 'wifi' | 'bluetooth'
],
)
"
>
<span /></button
><ChevronRight v-else :size="18" class="settings-chevron" />
</article>
</section>
<h2 class="settings-section-title">
{{ phone.t('Apps.settings.wallpaperPicker') }}
</h2>
<section class="wallpaper-picker">
<button
v-for="wallpaper in WALLPAPER_IDS"
:key="wallpaper"
type="button"
:class="[
`wallpaper--${wallpaper}`,
{ active: phone.preferences.settings.wallpaper === wallpaper },
]"
:aria-label="phone.t(`Apps.settings.wallpapers.${wallpaper}`)"
@click="phone.setWallpaper(wallpaper)"
/>
</section>
</section>
</main>
</template>