ADD - homescreen and apps

This commit is contained in:
Eichenholz
2026-08-03 15:47:09 +02:00
parent 1dd1d05afd
commit 6addba279d
47 changed files with 3141 additions and 139 deletions
+5 -19
View File
@@ -1,25 +1,11 @@
# sky_phone
FiveM phone scaffold for Sky-Systems, using Vue 3 and Konsta UI.
Standalone FiveM phone boilerplate built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. The resource currently exposes only the localized `/phone` toggle and the private NUI `ui:ready`/`close` callbacks.
The homescreen is an original implementation inspired by the interaction and layout concepts in [lukejacksonn/homescreen](https://github.com/lukejacksonn/homescreen), inspected at commit [`98a812f`](https://github.com/lukejacksonn/homescreen/tree/98a812f4f7c33594e791d65092f73b8f54b3c598). No source code or image assets from that project are included.
## Development
```powershell
cd frontend
pnpm install
pnpm dev
```
The development command starts Vite and a small mock server for NUI callbacks. The phone opens
automatically in browser development mode.
## Build
```powershell
cd frontend
pnpm build
```
The production build is published to `sky_phone/source/html/`. In FiveM, ensure `sky_base` before
`sky_phone` and use `/phone` to toggle the UI.
From `frontend/`, run `pnpm dev` for browser development. The phone opens automatically and NUI callbacks are mocked. Run `pnpm test`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` before packaging.
`pnpm build` uses `build.cjs` to replace `sky_phone/source/html` deterministically with the Vite output. Production assets use relative paths so they work through the FiveM NUI protocol.
+3 -1
View File
@@ -11,10 +11,11 @@
"typecheck": "vue-tsc --build",
"lint": "eslint .",
"format": "prettier --write src/ testserver/ build.cjs",
"test": "node --test"
"test": "vitest run"
},
"dependencies": {
"konsta": "~5.2.0",
"lucide-vue-next": "^0.525.0",
"pinia": "^3.0.3",
"vue": "^3.5.17",
"vue-router": "^4.5.1"
@@ -35,6 +36,7 @@
"tailwindcss": "^4.0.0",
"typescript": "~5.8.0",
"vite": "^7.0.0",
"vitest": "^3.2.4",
"vue-tsc": "^2.2.10"
}
}
+330
View File
@@ -11,6 +11,9 @@ importers:
konsta:
specifier: ~5.2.0
version: 5.2.0
lucide-vue-next:
specifier: ^0.525.0
version: 0.525.0(vue@3.5.40(typescript@5.8.3))
pinia:
specifier: ^3.0.3
version: 3.0.4(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3))
@@ -66,6 +69,9 @@ importers:
vite:
specifier: ^7.0.0
version: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
vue-tsc:
specifier: ^2.2.10
version: 2.2.12(typescript@5.8.3)
@@ -562,6 +568,12 @@ packages:
'@tsconfig/node22@22.0.5':
resolution: {integrity: sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@@ -640,6 +652,35 @@ packages:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.2.25
'@vitest/expect@3.2.7':
resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}
'@vitest/mocker@3.2.7':
resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.7':
resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==}
'@vitest/runner@3.2.7':
resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==}
'@vitest/snapshot@3.2.7':
resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==}
'@vitest/spy@3.2.7':
resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==}
'@vitest/utils@3.2.7':
resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==}
'@volar/language-core@2.4.15':
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
@@ -755,6 +796,10 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -782,14 +827,26 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -841,6 +898,10 @@ packages:
supports-color:
optional: true
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -859,6 +920,9 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
esbuild@0.28.1:
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
@@ -952,10 +1016,17 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1081,6 +1152,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@4.3.1:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
@@ -1182,6 +1256,15 @@ packages:
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lucide-vue-next@0.525.0:
resolution: {integrity: sha512-Xf8+x8B2DrnGDV/rxylS+KBp2FIe6ljwDn2JsGTZZvXIfhmm/q+nv8RuGO1OyoMjOVkkz7CqtUqJfwtFPRbB2w==}
deprecated: Package deprecated. Please use @lucide/vue instead.
peerDependencies:
vue: '>=3.0.1'
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1255,6 +1338,13 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
@@ -1349,6 +1439,9 @@ packages:
resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1357,6 +1450,12 @@ packages:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -1369,6 +1468,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
superjson@2.2.6:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
@@ -1395,10 +1497,28 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyspy@4.0.4:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@@ -1445,6 +1565,11 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@7.3.6:
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1485,6 +1610,34 @@ packages:
yaml:
optional: true
vitest@3.2.7:
resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.7
'@vitest/ui': 3.2.7
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
@@ -1518,6 +1671,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@@ -1884,6 +2042,13 @@ snapshots:
'@tsconfig/node22@22.0.5': {}
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/deep-eql@4.0.2': {}
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.9': {}
@@ -1991,6 +2156,48 @@ snapshots:
vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
vue: 3.5.40(typescript@5.8.3)
'@vitest/expect@3.2.7':
dependencies:
'@types/chai': 5.2.3
'@vitest/spy': 3.2.7
'@vitest/utils': 3.2.7
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))':
dependencies:
'@vitest/spy': 3.2.7
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
'@vitest/pretty-format@3.2.7':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.7':
dependencies:
'@vitest/utils': 3.2.7
pathe: 2.0.3
strip-literal: 3.1.0
'@vitest/snapshot@3.2.7':
dependencies:
'@vitest/pretty-format': 3.2.7
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@3.2.7':
dependencies:
tinyspy: 4.0.4
'@vitest/utils@3.2.7':
dependencies:
'@vitest/pretty-format': 3.2.7
loupe: 3.2.1
tinyrainbow: 2.0.0
'@volar/language-core@2.4.15':
dependencies:
'@volar/source-map': 2.4.15
@@ -2145,6 +2352,8 @@ snapshots:
argparse@2.0.1: {}
assertion-error@2.0.1: {}
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
@@ -2170,13 +2379,25 @@ snapshots:
dependencies:
fill-range: 7.1.1
cac@6.7.14: {}
callsites@3.1.0: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
check-error@2.1.3: {}
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -2225,6 +2446,8 @@ snapshots:
dependencies:
ms: 2.1.3
deep-eql@5.0.2: {}
deep-is@0.1.4: {}
detect-libc@2.1.2: {}
@@ -2238,6 +2461,8 @@ snapshots:
entities@7.0.1: {}
es-module-lexer@1.7.0: {}
esbuild@0.28.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.1
@@ -2378,8 +2603,14 @@ snapshots:
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
esutils@2.0.3: {}
expect-type@1.4.0: {}
fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
@@ -2474,6 +2705,8 @@ snapshots:
jiti@2.7.0: {}
js-tokens@9.0.1: {}
js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
@@ -2552,6 +2785,12 @@ snapshots:
lodash.merge@4.6.2: {}
loupe@3.2.1: {}
lucide-vue-next@0.525.0(vue@3.5.40(typescript@5.8.3)):
dependencies:
vue: 3.5.40(typescript@5.8.3)
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -2618,6 +2857,10 @@ snapshots:
path-key@3.1.1: {}
pathe@2.0.3: {}
pathval@2.0.1: {}
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {}
@@ -2714,10 +2957,16 @@ snapshots:
shell-quote@1.9.0: {}
siginfo@2.0.0: {}
source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
stackback@0.0.2: {}
std-env@3.10.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -2730,6 +2979,10 @@ snapshots:
strip-json-comments@3.1.1: {}
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
superjson@2.2.6:
dependencies:
copy-anything: 4.0.5
@@ -2752,11 +3005,21 @@ snapshots:
tapable@2.3.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.5)
picomatch: 4.0.5
tinypool@1.1.1: {}
tinyrainbow@2.0.0: {}
tinyspy@4.0.4: {}
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@@ -2796,6 +3059,27 @@ snapshots:
vary@1.1.2: {}
vite-node@3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
transitivePeerDependencies:
- '@types/node'
- jiti
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0):
dependencies:
esbuild: 0.28.1
@@ -2810,6 +3094,47 @@ snapshots:
jiti: 2.7.0
lightningcss: 1.32.0
vitest@3.2.7(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.7
'@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))
'@vitest/pretty-format': 3.2.7
'@vitest/runner': 3.2.7
'@vitest/snapshot': 3.2.7
'@vitest/spy': 3.2.7
'@vitest/utils': 3.2.7
chai: 5.3.3
debug: 4.4.3
expect-type: 1.4.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.5
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.17
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
vite-node: 3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.20.1
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vscode-uri@3.1.0: {}
vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)):
@@ -2849,6 +3174,11 @@ snapshots:
dependencies:
isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:
+20 -11
View File
@@ -1,9 +1,13 @@
<script setup lang="ts">
import { kApp } from 'konsta/vue'
import { onBeforeUnmount, onMounted } from 'vue'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
import { usePhoneStore, type PhoneOpenPayload } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
import SpringboardView from '@/views/SpringboardView.vue'
type AppMessage = {
type?: string
@@ -11,6 +15,8 @@ type AppMessage = {
}
const phone = usePhoneStore()
const route = useRoute()
const isAppRoute = computed(() => route.name === 'app')
function onMessage(event: MessageEvent<AppMessage>): void {
if (event.data?.type === 'app:open') {
@@ -22,7 +28,6 @@ function onMessage(event: MessageEvent<AppMessage>): void {
function onKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape' || !phone.isOpen) return
phone.close()
void nuiCall('close')
}
@@ -31,10 +36,7 @@ onMounted(() => {
window.addEventListener('message', onMessage)
window.addEventListener('keydown', onKeydown)
void nuiCall('ui:ready')
if (import.meta.env.DEV) {
phone.open()
}
if (import.meta.env.DEV) phone.open()
})
onBeforeUnmount(() => {
@@ -45,11 +47,18 @@ onBeforeUnmount(() => {
<template>
<main v-if="phone.isOpen" class="phone-stage">
<section class="phone-device" aria-label="Phone preview">
<div class="phone-device__speaker" aria-hidden="true"></div>
<div class="phone-screen">
<k-app theme="ios" safe-areas class="phone-app">
<RouterView />
<section class="phone-device" :aria-label="phone.t('Common.phone')">
<div class="phone-device__island" aria-hidden="true"></div>
<div class="phone-screen" :class="{ 'phone-screen--app': isAppRoute }">
<k-app theme="ios" dark safe-areas class="phone-app">
<PhoneStatusBar />
<SpringboardView />
<RouterView v-slot="{ Component }">
<Transition name="app-window">
<component :is="Component" v-if="isAppRoute" />
</Transition>
</RouterView>
<PhoneHomeIndicator />
</k-app>
</div>
</section>
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneAppDefinition } from '@/types/apps'
const props = withDefaults(
defineProps<{
app: PhoneAppDefinition
compact?: boolean
showLabel?: boolean
}>(),
{
compact: false,
showLabel: true,
},
)
const phone = usePhoneStore()
const router = useRouter()
function launch(event: MouseEvent): void {
const button = event.currentTarget as HTMLElement
const screen = button.closest('.phone-screen')
if (screen) {
const origin = button.getBoundingClientRect()
const target = screen.getBoundingClientRect()
phone.setLaunchOrigin({
height: origin.height,
scaleX: origin.width / target.width,
scaleY: origin.height / target.height,
width: origin.width,
x: origin.left - target.left,
y: origin.top - target.top,
})
} else {
phone.setLaunchOrigin(null)
}
void router.push(props.app.route)
}
</script>
<template>
<button
class="app-icon-button"
:class="{ 'app-icon-button--compact': compact }"
type="button"
: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>
<span v-if="showLabel" class="app-icon-label">{{
phone.t(app.labelKey)
}}</span>
</button>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePhoneStore } from '@/stores/phone'
const route = useRoute()
const router = useRouter()
const phone = usePhoneStore()
const isApp = computed(() => route.name === 'app')
function goHome(): void {
if (isApp.value) void router.push('/')
}
</script>
<template>
<button
class="phone-home-indicator"
:class="{ 'phone-home-indicator--interactive': isApp }"
type="button"
:aria-label="phone.t('Common.home')"
@click="goHome"
>
<span aria-hidden="true"></span>
</button>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { BatteryMedium, Signal, Wifi } from 'lucide-vue-next'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
const time = ref('')
let intervalId: number | undefined
function updateTime(): void {
time.value = new Intl.DateTimeFormat([], {
hour: 'numeric',
minute: '2-digit',
}).format(new Date())
}
onMounted(() => {
updateTime()
intervalId = window.setInterval(updateTime, 30_000)
})
onBeforeUnmount(() => {
if (intervalId !== undefined) window.clearInterval(intervalId)
})
</script>
<template>
<header class="phone-status-bar" :aria-label="phone.t('Common.phoneStatus')">
<time>{{ time }}</time>
<div class="phone-status-bar__indicators" aria-hidden="true">
<Signal :size="12" :stroke-width="2.5" />
<Wifi :size="13" :stroke-width="2.5" />
<BatteryMedium :size="16" :stroke-width="2.4" />
</div>
</header>
</template>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import {
BatteryCharging,
CloudSun,
Pause,
Play,
SkipForward,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
const now = ref(new Date())
const playing = ref(false)
let intervalId: number | undefined
const date = computed(() =>
new Intl.DateTimeFormat(phone.lang, {
month: 'long',
weekday: 'long',
}).format(now.value),
)
const day = computed(() => now.value.getDate())
onMounted(() => {
intervalId = window.setInterval(() => {
now.value = new Date()
}, 60_000)
})
onBeforeUnmount(() => {
if (intervalId !== undefined) window.clearInterval(intervalId)
})
</script>
<template>
<div class="widgets-stack">
<article class="widget widget--weather">
<div>
<span>{{ phone.t('Home.widgets.weather.city') }}</span>
<strong>21°</strong>
<small>{{ phone.t('Home.widgets.weather.condition') }}</small>
</div>
<CloudSun :size="48" aria-hidden="true" />
</article>
<div class="widget-row">
<article class="widget widget--calendar">
<span>{{ date }}</span>
<strong>{{ day }}</strong>
<small>{{ phone.t('Home.widgets.calendar.event') }}</small>
</article>
<article class="widget widget--battery">
<BatteryCharging :size="25" aria-hidden="true" />
<strong>78%</strong>
<small>{{ phone.t('Home.widgets.battery.label') }}</small>
</article>
</div>
<article class="widget widget--media">
<div class="widget--media__art" aria-hidden="true"></div>
<div class="widget--media__copy">
<strong>{{ phone.t('Home.widgets.media.title') }}</strong>
<small>{{ phone.t('Home.widgets.media.artist') }}</small>
</div>
<button
type="button"
:aria-label="
phone.t(
playing ? 'Home.widgets.media.pause' : 'Home.widgets.media.play',
)
"
@click="playing = !playing"
>
<Pause v-if="playing" :size="20" fill="currentColor" />
<Play v-else :size="20" fill="currentColor" />
</button>
<SkipForward :size="19" aria-hidden="true" />
</article>
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { PHONE_APPS } from './apps'
describe('app registry', () => {
it('has unique ids and routes with the reference dock order', () => {
expect(new Set(PHONE_APPS.map((app) => app.id)).size).toBe(
PHONE_APPS.length,
)
expect(PHONE_APPS.every((app) => app.route === `/apps/${app.id}`)).toBe(
true,
)
expect(
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
.map((app) => app.id),
).toEqual(['app-store', 'calculator', 'camera', 'clock'])
})
})
+99
View File
@@ -0,0 +1,99 @@
import {
Calculator,
Camera,
Clock3,
Images,
Settings,
ShoppingBag,
} from 'lucide-vue-next'
import { defineAsyncComponent, markRaw } from 'vue'
import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
export const PHONE_APPS: PhoneAppDefinition[] = [
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CalculatorApp.vue')),
),
dockOrder: 1,
gridOrder: 0,
icon: markRaw(Calculator),
iconClass: 'app-icon--calculator',
id: 'calculator',
labelKey: 'Apps.calculator.name',
route: '/apps/calculator',
},
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CameraApp.vue')),
),
dockOrder: 2,
gridOrder: 1,
icon: markRaw(Camera),
iconClass: 'app-icon--camera',
id: 'camera',
labelKey: 'Apps.camera.name',
route: '/apps/camera',
},
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/ClockApp.vue')),
),
dockOrder: 3,
gridOrder: 2,
icon: markRaw(Clock3),
iconClass: 'app-icon--clock',
id: 'clock',
labelKey: 'Apps.clock.name',
route: '/apps/clock',
},
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/PhotosApp.vue')),
),
dockOrder: null,
gridOrder: 3,
icon: markRaw(Images),
iconClass: 'app-icon--photos',
id: 'photos',
labelKey: 'Apps.photos.name',
route: '/apps/photos',
},
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/AppStoreApp.vue')),
),
dockOrder: 0,
gridOrder: 4,
icon: markRaw(ShoppingBag),
iconClass: 'app-icon--store',
id: 'app-store',
labelKey: 'Apps.appStore.name',
route: '/apps/app-store',
},
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/SettingsApp.vue')),
),
dockOrder: null,
gridOrder: 5,
icon: markRaw(Settings),
iconClass: 'app-icon--settings',
id: 'settings',
labelKey: 'Apps.settings.name',
route: '/apps/settings',
},
]
export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id)
export function getPhoneApp(
id: string | string[] | undefined,
): PhoneAppDefinition | undefined {
const appId = Array.isArray(id) ? id[0] : id
return PHONE_APPS.find((app) => app.id === appId)
}
export function isPhoneAppId(value: string): value is PhoneAppId {
return PHONE_APP_IDS.includes(value as PhoneAppId)
}
+14 -2
View File
@@ -1,13 +1,25 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import PhoneHomeView from '@/views/PhoneHomeView.vue'
import { isPhoneAppId } from '@/config/apps'
import PhoneAppWindow from '@/views/PhoneAppWindow.vue'
import SpringboardView from '@/views/SpringboardView.vue'
export default createRouter({
history: createWebHashHistory(),
routes: [
{
component: PhoneHomeView,
component: SpringboardView,
name: 'home',
path: '/',
},
{
beforeEnter: (to) =>
typeof to.params.appId === 'string' && isPhoneAppId(to.params.appId)
? true
: '/',
component: PhoneAppWindow,
name: 'app',
path: '/apps/:appId',
},
],
})
+40
View File
@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import {
calculatorPercent,
chooseCalculatorOperator,
clearCalculator,
inputDecimal,
inputDigit,
resolveCalculator,
toggleCalculatorSign,
type CalculatorOperator,
type CalculatorState,
} from '@/utils/calculator'
export const useCalculatorStore = defineStore('calculator', {
state: (): CalculatorState => clearCalculator(),
actions: {
chooseOperator(operator: CalculatorOperator): void {
Object.assign(this, chooseCalculatorOperator(this.$state, operator))
},
clear(): void {
Object.assign(this, clearCalculator())
},
decimal(): void {
Object.assign(this, inputDecimal(this.$state))
},
digit(value: string): void {
Object.assign(this, inputDigit(this.$state, value))
},
equals(): void {
Object.assign(this, resolveCalculator(this.$state))
},
percent(): void {
Object.assign(this, calculatorPercent(this.$state))
},
toggleSign(): void {
Object.assign(this, toggleCalculatorSign(this.$state))
},
},
})
+85
View File
@@ -0,0 +1,85 @@
import { defineStore } from 'pinia'
import { elapsedMilliseconds, remainingMilliseconds } from '@/utils/clock'
export const useClockStore = defineStore('clock', {
state: () => ({
alarms: [
{
enabled: true,
id: 'weekday',
labelKey: 'Apps.clock.alarm.weekday',
time: '07:30',
},
{
enabled: false,
id: 'weekend',
labelKey: 'Apps.clock.alarm.weekend',
time: '09:00',
},
],
laps: [] as number[],
stopwatchAccumulated: 0,
stopwatchStartedAt: null as number | null,
timerDuration: 5 * 60 * 1000,
timerRemainingAtStart: 5 * 60 * 1000,
timerStartedAt: null as number | null,
}),
actions: {
addLap(now: number): void {
if (this.stopwatchStartedAt === null) return
this.laps.unshift(
elapsedMilliseconds(
this.stopwatchAccumulated,
this.stopwatchStartedAt,
now,
),
)
},
pauseStopwatch(now: number): void {
if (this.stopwatchStartedAt === null) return
this.stopwatchAccumulated = elapsedMilliseconds(
this.stopwatchAccumulated,
this.stopwatchStartedAt,
now,
)
this.stopwatchStartedAt = null
},
pauseTimer(now: number): void {
if (this.timerStartedAt === null) return
this.timerRemainingAtStart = remainingMilliseconds(
this.timerRemainingAtStart,
this.timerStartedAt,
now,
)
this.timerStartedAt = null
},
resetStopwatch(): void {
this.stopwatchAccumulated = 0
this.stopwatchStartedAt = null
this.laps = []
},
resetTimer(): void {
this.timerRemainingAtStart = this.timerDuration
this.timerStartedAt = null
},
setTimerMinutes(minutes: number): void {
this.timerDuration =
Math.max(1, Math.min(60, Math.round(minutes))) * 60 * 1000
this.resetTimer()
},
startStopwatch(now: number): void {
if (this.stopwatchStartedAt !== null) return
this.stopwatchStartedAt = now
},
startTimer(now: number): void {
if (this.timerStartedAt !== null || this.timerRemainingAtStart <= 0)
return
this.timerStartedAt = now
},
toggleAlarm(id: string): void {
const alarm = this.alarms.find((candidate) => candidate.id === id)
if (alarm) alarm.enabled = !alarm.enabled
},
},
})
+67
View File
@@ -0,0 +1,67 @@
import { defineStore } from 'pinia'
export type PhonePhoto = {
capturedAt: number
gradient: string
id: string
titleKey: string
}
const samplePhotos: PhonePhoto[] = [
{
capturedAt: Date.now() - 86_400_000,
gradient: 'linear-gradient(145deg, #ff9a62, #5f2c82 58%, #141e30)',
id: 'sunset-drive',
titleKey: 'Apps.photos.samples.sunset',
},
{
capturedAt: Date.now() - 172_800_000,
gradient: 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)',
id: 'ocean-air',
titleKey: 'Apps.photos.samples.ocean',
},
{
capturedAt: Date.now() - 259_200_000,
gradient: 'linear-gradient(135deg, #fbc2eb, #a6c1ee 48%, #302b63)',
id: 'city-lights',
titleKey: 'Apps.photos.samples.city',
},
{
capturedAt: Date.now() - 345_600_000,
gradient: 'linear-gradient(150deg, #f6d365, #fda085 45%, #512b58)',
id: 'desert-road',
titleKey: 'Apps.photos.samples.desert',
},
]
export const useMediaStore = defineStore('media', {
state: () => ({
cameraFacing: 'rear' as 'front' | 'rear',
cameraMode: 'photo' as 'photo' | 'portrait' | 'video',
cameraZoom: 1,
captures: [] as PhonePhoto[],
claimedApps: [] as string[],
}),
getters: {
photos: (state): PhonePhoto[] => [...state.captures, ...samplePhotos],
},
actions: {
capture(): void {
const gradients = [
'linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)',
'linear-gradient(150deg, #00c9a7, #4d8076 46%, #1f3a5f)',
'linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)',
]
const captureNumber = this.captures.length
this.captures.unshift({
capturedAt: Date.now(),
gradient: gradients[captureNumber % gradients.length],
id: `capture-${Date.now()}`,
titleKey: 'Apps.photos.samples.capture',
})
},
claimApp(id: string): void {
if (!this.claimedApps.includes(id)) this.claimedApps.push(id)
},
},
})
+150 -4
View File
@@ -1,5 +1,13 @@
import { defineStore } from 'pinia'
import type { AppLaunchOrigin } from '@/types/apps'
import { clampPage } from '@/utils/pages'
import {
readPhonePreferences,
type WallpaperId,
writePhonePreferences,
} from '@/utils/preferences'
type LocaleTree = Record<string, unknown>
export type PhoneOpenPayload = {
@@ -8,11 +16,129 @@ export type PhoneOpenPayload = {
}
const defaultLocales: LocaleTree = {
Phone: {
Apps: {
appStore: {
name: 'App Store',
eyebrow: 'Discover',
featured: 'Featured',
heroTitle: 'Apps for every day',
heroBody: 'Fresh ideas, built for your life in the city.',
get: 'GET',
open: 'OPEN',
searchPlaceholder: 'Games, Apps, Stories and More',
tabs: {
today: 'Today',
apps: 'Apps',
games: 'Games',
arcade: 'Arcade',
search: 'Search',
},
catalog: {
orbit: 'Plan your day',
studio: 'Create something new',
trail: 'Explore nearby',
prism: 'A colorful puzzle',
},
},
calculator: { name: 'Calculator' },
camera: {
name: 'Camera',
shutter: 'Take photo',
flip: 'Flip camera',
modes: { video: 'Video', photo: 'Photo', portrait: 'Portrait' },
},
clock: {
name: 'Clock',
lap: 'Lap',
minutes: 'Minutes',
tabs: {
world: 'World Clock',
alarm: 'Alarm',
stopwatch: 'Stopwatch',
timer: 'Timer',
},
cities: {
cupertino: 'Cupertino',
newYork: 'New York',
london: 'London',
tokyo: 'Tokyo',
},
alarm: { weekday: 'Weekdays', weekend: 'Weekend' },
},
photos: {
name: 'Photos',
searchPlaceholder: 'Photos, people, places...',
recents: 'Recents',
favorites: 'Favorites',
items: 'items',
memories: 'Memories',
featured: 'City colors',
tabs: {
library: 'Library',
forYou: 'For You',
albums: 'Albums',
search: 'Search',
},
samples: {
sunset: 'Sunset drive',
ocean: 'Ocean air',
city: 'City lights',
desert: 'Desert road',
capture: 'Camera capture',
},
},
settings: {
name: 'Settings',
searchPlaceholder: 'Search',
wifi: 'Wi-Fi',
bluetooth: 'Bluetooth',
airplaneMode: 'Airplane Mode',
wallpaper: 'Wallpaper',
toggle: {
wifi: 'Toggle Wi-Fi',
bluetooth: 'Toggle Bluetooth',
airplaneMode: 'Toggle Airplane Mode',
},
wallpapers: {
midnight: 'Midnight wallpaper',
aurora: 'Aurora wallpaper',
ember: 'Ember wallpaper',
},
},
},
Common: {
cancel: 'Cancel',
close: 'Close',
readyBody: 'Konsta UI is connected to the FiveM NUI bridge.',
readyTitle: 'Boilerplate ready',
title: 'Phone',
home: 'Home',
pause: 'Pause',
phone: 'Phone',
phoneStatus: 'Phone status',
reset: 'Reset',
search: 'Search',
start: 'Start',
stop: 'Stop',
},
Home: {
appLibrary: 'App Library',
appLibrarySearch: 'Search apps',
apps: 'Apps',
dock: 'Dock',
noApps: 'No apps found',
page: 'Page',
pages: 'Home screen pages',
groups: { 0: 'Productivity', 1: 'Creativity' },
widgets: {
label: 'Widgets',
weather: { city: 'Los Santos', condition: 'Partly Cloudy' },
calendar: { event: 'No more events today' },
battery: { label: 'Phone' },
media: {
title: 'Night Drive',
artist: 'Sky Radio',
play: 'Play',
pause: 'Pause',
},
},
},
}
@@ -30,9 +156,12 @@ function getByPath(source: LocaleTree, path: string): unknown {
export const usePhoneStore = defineStore('phone', {
state: () => ({
currentPage: 1,
isOpen: false,
lang: 'en',
launchOrigin: null as AppLaunchOrigin | null,
locales: defaultLocales,
preferences: readPhonePreferences(),
}),
actions: {
close(): void {
@@ -43,6 +172,23 @@ export const usePhoneStore = defineStore('phone', {
this.locales = payload.locales ?? defaultLocales
this.isOpen = true
},
setCurrentPage(page: number): void {
this.currentPage = clampPage(page)
},
setLaunchOrigin(origin: AppLaunchOrigin | null): void {
this.launchOrigin = origin
},
setSetting(
key: Exclude<keyof typeof this.preferences.settings, 'wallpaper'>,
value: boolean,
): void {
this.preferences.settings[key] = value
writePhonePreferences(this.preferences)
},
setWallpaper(wallpaper: WallpaperId): void {
this.preferences.settings.wallpaper = wallpaper
writePhonePreferences(this.preferences)
},
t(path: string): string {
const translated = getByPath(this.locales, path)
const fallback = getByPath(defaultLocales, path)
+29
View File
@@ -0,0 +1,29 @@
import type { Component } from 'vue'
export type PhoneAppId =
| 'calculator'
| 'camera'
| 'clock'
| 'photos'
| 'app-store'
| 'settings'
export type AppLaunchOrigin = {
height: number
scaleX: number
scaleY: number
width: number
x: number
y: number
}
export type PhoneAppDefinition = {
component: Component
dockOrder: number | null
gridOrder: number
icon: Component
iconClass: string
id: PhoneAppId
labelKey: string
route: `/apps/${PhoneAppId}`
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import {
calculate,
chooseCalculatorOperator,
clearCalculator,
inputDigit,
resolveCalculator,
} from './calculator'
describe('calculator', () => {
it('calculates all four operations', () => {
expect(calculate(8, 2, 'add')).toBe(10)
expect(calculate(8, 2, 'subtract')).toBe(6)
expect(calculate(8, 2, 'multiply')).toBe(16)
expect(calculate(8, 2, 'divide')).toBe(4)
})
it('chains operations and handles division by zero', () => {
let state = inputDigit(clearCalculator(), '8')
state = chooseCalculatorOperator(state, 'add')
state = inputDigit(state, '2')
state = chooseCalculatorOperator(state, 'multiply')
expect(state.display).toBe('10')
state = inputDigit(state, '3')
expect(resolveCalculator(state).display).toBe('30')
expect(calculate(4, 0, 'divide')).toBeNull()
})
})
+126
View File
@@ -0,0 +1,126 @@
export type CalculatorOperator = 'add' | 'subtract' | 'multiply' | 'divide'
export type CalculatorState = {
accumulator: number | null
display: string
error: boolean
pendingOperator: CalculatorOperator | null
waitingForOperand: boolean
}
export const INITIAL_CALCULATOR_STATE: CalculatorState = {
accumulator: null,
display: '0',
error: false,
pendingOperator: null,
waitingForOperand: false,
}
function formatNumber(value: number): string {
if (!Number.isFinite(value)) return 'Error'
const rounded = Number(value.toPrecision(12))
const rendered = String(rounded)
return rendered.length <= 12 ? rendered : rounded.toExponential(6)
}
export function calculate(
left: number,
right: number,
operator: CalculatorOperator,
): number | null {
if (operator === 'add') return left + right
if (operator === 'subtract') return left - right
if (operator === 'multiply') return left * right
if (right === 0) return null
return left / right
}
export function clearCalculator(): CalculatorState {
return { ...INITIAL_CALCULATOR_STATE }
}
export function inputDigit(
state: CalculatorState,
digit: string,
): CalculatorState {
if (!/^\d$/.test(digit)) return state
if (state.error || state.waitingForOperand) {
return { ...state, display: digit, error: false, waitingForOperand: false }
}
if (state.display === '0') return { ...state, display: digit }
if (state.display.replace('-', '').replace('.', '').length >= 10) return state
return { ...state, display: `${state.display}${digit}` }
}
export function inputDecimal(state: CalculatorState): CalculatorState {
if (state.error || state.waitingForOperand) {
return { ...state, display: '0.', error: false, waitingForOperand: false }
}
return state.display.includes('.')
? state
: { ...state, display: `${state.display}.` }
}
export function toggleCalculatorSign(state: CalculatorState): CalculatorState {
if (state.error || state.display === '0') return state
return {
...state,
display: state.display.startsWith('-')
? state.display.slice(1)
: `-${state.display}`,
}
}
export function calculatorPercent(state: CalculatorState): CalculatorState {
if (state.error) return state
return { ...state, display: formatNumber(Number(state.display) / 100) }
}
export function chooseCalculatorOperator(
state: CalculatorState,
operator: CalculatorOperator,
): CalculatorState {
if (state.error) return clearCalculator()
const input = Number(state.display)
let accumulator = state.accumulator
if (
accumulator !== null &&
state.pendingOperator &&
!state.waitingForOperand
) {
const result = calculate(accumulator, input, state.pendingOperator)
if (result === null)
return { ...clearCalculator(), display: 'Error', error: true }
accumulator = result
} else if (accumulator === null) {
accumulator = input
}
return {
accumulator,
display: formatNumber(accumulator),
error: false,
pendingOperator: operator,
waitingForOperand: true,
}
}
export function resolveCalculator(state: CalculatorState): CalculatorState {
if (state.error || state.accumulator === null || !state.pendingOperator)
return state
const result = calculate(
state.accumulator,
Number(state.display),
state.pendingOperator,
)
if (result === null)
return { ...clearCalculator(), display: 'Error', error: true }
return {
accumulator: null,
display: formatNumber(result),
error: false,
pendingOperator: null,
waitingForOperand: true,
}
}
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { elapsedMilliseconds, remainingMilliseconds } from './clock'
describe('timestamp clocks', () => {
it('derives elapsed and remaining time without an interval state', () => {
expect(elapsedMilliseconds(500, 1000, 2500)).toBe(2000)
expect(remainingMilliseconds(5000, 1000, 2500)).toBe(3500)
expect(remainingMilliseconds(100, 1000, 2500)).toBe(0)
})
})
+33
View File
@@ -0,0 +1,33 @@
export function elapsedMilliseconds(
accumulated: number,
startedAt: number | null,
now: number,
): number {
return Math.max(0, accumulated + (startedAt === null ? 0 : now - startedAt))
}
export function remainingMilliseconds(
remainingAtStart: number,
startedAt: number | null,
now: number,
): number {
return Math.max(
0,
remainingAtStart - (startedAt === null ? 0 : now - startedAt),
)
}
export function formatStopwatch(milliseconds: number): string {
const totalCentiseconds = Math.floor(milliseconds / 10)
const minutes = Math.floor(totalCentiseconds / 6000)
const seconds = Math.floor((totalCentiseconds % 6000) / 100)
const centiseconds = totalCentiseconds % 100
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(centiseconds).padStart(2, '0')}`
}
export function formatTimer(milliseconds: number): string {
const totalSeconds = Math.ceil(milliseconds / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest'
import { clampPage } from './pages'
describe('page clamping', () => {
it('keeps pages in range', () => {
expect(clampPage(-4)).toBe(0)
expect(clampPage(1)).toBe(1)
expect(clampPage(9)).toBe(2)
})
})
+9
View File
@@ -0,0 +1,9 @@
export const SPRINGBOARD_PAGE_COUNT = 3
export function clampPage(
page: number,
pageCount = SPRINGBOARD_PAGE_COUNT,
): number {
if (!Number.isFinite(page) || pageCount <= 0) return 0
return Math.min(pageCount - 1, Math.max(0, Math.round(page)))
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_PHONE_PREFERENCES, parsePhonePreferences } from './preferences'
describe('preferences', () => {
it('falls back for malformed and obsolete records', () => {
expect(parsePhonePreferences('{')).toEqual(DEFAULT_PHONE_PREFERENCES)
expect(parsePhonePreferences('{"version":2}')).toEqual(
DEFAULT_PHONE_PREFERENCES,
)
})
it('keeps valid harmless preferences', () => {
const value = parsePhonePreferences(
JSON.stringify({
version: 1,
settings: { wifi: false, wallpaper: 'ember' },
}),
)
expect(value.settings.wifi).toBe(false)
expect(value.settings.wallpaper).toBe('ember')
})
})
+87
View File
@@ -0,0 +1,87 @@
export const PHONE_PREFERENCES_KEY = 'sky_phone.preferences.v1'
export type WallpaperId = 'midnight' | 'aurora' | 'ember'
export type PhonePreferencesV1 = {
settings: {
airplaneMode: boolean
bluetooth: boolean
cellular: boolean
personalHotspot: boolean
vpn: boolean
wallpaper: WallpaperId
wifi: boolean
}
version: 1
}
export const DEFAULT_PHONE_PREFERENCES: PhonePreferencesV1 = {
settings: {
airplaneMode: false,
bluetooth: true,
cellular: true,
personalHotspot: false,
vpn: false,
wallpaper: 'midnight',
wifi: true,
},
version: 1,
}
export const WALLPAPER_IDS: WallpaperId[] = ['midnight', 'aurora', 'ember']
export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
if (!raw) return structuredClone(DEFAULT_PHONE_PREFERENCES)
try {
const parsed = JSON.parse(raw) as Partial<PhonePreferencesV1>
const settings = parsed.settings
if (parsed.version !== 1 || !settings || typeof settings !== 'object') {
return structuredClone(DEFAULT_PHONE_PREFERENCES)
}
const defaults = DEFAULT_PHONE_PREFERENCES.settings
return {
settings: {
airplaneMode:
typeof settings.airplaneMode === 'boolean'
? settings.airplaneMode
: defaults.airplaneMode,
bluetooth:
typeof settings.bluetooth === 'boolean'
? settings.bluetooth
: defaults.bluetooth,
cellular:
typeof settings.cellular === 'boolean'
? settings.cellular
: defaults.cellular,
personalHotspot:
typeof settings.personalHotspot === 'boolean'
? settings.personalHotspot
: defaults.personalHotspot,
vpn: typeof settings.vpn === 'boolean' ? settings.vpn : defaults.vpn,
wallpaper: WALLPAPER_IDS.includes(settings.wallpaper as WallpaperId)
? (settings.wallpaper as WallpaperId)
: defaults.wallpaper,
wifi:
typeof settings.wifi === 'boolean' ? settings.wifi : defaults.wifi,
},
version: 1,
}
} catch {
return structuredClone(DEFAULT_PHONE_PREFERENCES)
}
}
export function readPhonePreferences(): PhonePreferencesV1 {
return parsePhonePreferences(
window.localStorage.getItem(PHONE_PREFERENCES_KEY),
)
}
export function writePhonePreferences(preferences: PhonePreferencesV1): void {
window.localStorage.setItem(
PHONE_PREFERENCES_KEY,
JSON.stringify(preferences),
)
}
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { getPhoneApp } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
const route = useRoute()
const phone = usePhoneStore()
const app = computed(() => getPhoneApp(route.params.appId))
const launchStyle = computed(() => {
const origin = phone.launchOrigin
return {
'--launch-scale-x': origin?.scaleX ?? 0.82,
'--launch-scale-y': origin?.scaleY ?? 0.82,
'--launch-x': `${origin?.x ?? 35}px`,
'--launch-y': `${origin?.y ?? 70}px`,
}
})
</script>
<template>
<div v-if="app" class="phone-app-window" :style="launchStyle">
<Suspense>
<component :is="app.component" />
<template #fallback>
<div class="app-loading">{{ phone.t('Common.loading') }}</div>
</template>
</Suspense>
</div>
</template>
-36
View File
@@ -1,36 +0,0 @@
<script setup lang="ts">
import { kBlock, kButton, kGlass, kNavbar, kPage } from 'konsta/vue'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
const phone = usePhoneStore()
function closePhone(): void {
phone.close()
void nuiCall('close')
}
</script>
<template>
<k-page class="phone-home">
<k-navbar :title="phone.t('Phone.title')" class="top-0 sticky" />
<div class="phone-home__content">
<k-glass class="mx-4 rounded-3xl p-4">
<h1 class="mb-2 text-xl font-semibold">
{{ phone.t('Phone.readyTitle') }}
</h1>
<p class="text-sm leading-6 text-black/60">
{{ phone.t('Phone.readyBody') }}
</p>
</k-glass>
<k-block inset>
<k-button large rounded @click="closePhone">
{{ phone.t('Phone.close') }}
</k-button>
</k-block>
</div>
</k-page>
</template>
+188
View File
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { Search, X } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import SpringboardWidgets from '@/components/SpringboardWidgets.vue'
import { PHONE_APPS } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import { clampPage, SPRINGBOARD_PAGE_COUNT } from '@/utils/pages'
const phone = usePhoneStore()
const searchQuery = ref('')
const searchFocused = ref(false)
const dragOffset = ref(0)
const dragging = ref(false)
let pointerStart = 0
let pointerStartedAt = 0
const gridApps = computed(() =>
[...PHONE_APPS].sort((a, b) => a.gridOrder - b.gridOrder),
)
const dockApps = computed(() =>
PHONE_APPS.filter((app) => app.dockOrder !== null).sort(
(a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0),
),
)
const filteredApps = computed(() => {
const query = searchQuery.value.trim().toLocaleLowerCase(phone.lang)
if (!query) return gridApps.value
return gridApps.value.filter((app) =>
phone.t(app.labelKey).toLocaleLowerCase(phone.lang).includes(query),
)
})
const appGroups = computed(() => [
gridApps.value.slice(0, 3),
gridApps.value.slice(3, 6),
])
const trackStyle = computed(() => ({
'--drag-offset': `${dragOffset.value}px`,
'--springboard-page': phone.currentPage,
}))
function onPointerDown(event: PointerEvent): void {
const target = event.target as HTMLElement
if (target.closest('button, input')) return
pointerStart = event.clientX
pointerStartedAt = Date.now()
dragging.value = true
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function onPointerMove(event: PointerEvent): void {
if (!dragging.value) return
dragOffset.value = event.clientX - pointerStart
}
function finishPointer(event: PointerEvent): void {
if (!dragging.value) return
const distance = event.clientX - pointerStart
const elapsed = Math.max(1, Date.now() - pointerStartedAt)
const velocity = Math.abs(distance) / elapsed
if (Math.abs(distance) > 48 || velocity > 0.45) {
phone.setCurrentPage(phone.currentPage + (distance < 0 ? 1 : -1))
}
dragging.value = false
dragOffset.value = 0
}
function clearSearch(): void {
searchQuery.value = ''
searchFocused.value = false
}
</script>
<template>
<section
class="springboard"
:class="[
`wallpaper--${phone.preferences.settings.wallpaper}`,
{ 'springboard--dragging': dragging },
]"
>
<div
class="springboard-track"
:style="trackStyle"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="finishPointer"
@pointercancel="finishPointer"
>
<section
class="springboard-page springboard-page--widgets"
:aria-label="phone.t('Home.widgets.label')"
>
<SpringboardWidgets />
</section>
<section
class="springboard-page springboard-page--apps"
:aria-label="phone.t('Home.apps')"
>
<div class="app-grid">
<AppIcon v-for="app in gridApps" :key="app.id" :app="app" />
</div>
</section>
<section
class="springboard-page springboard-page--library"
:aria-label="phone.t('Home.appLibrary')"
>
<div
class="app-library-search"
:class="{ 'app-library-search--focused': searchFocused }"
>
<Search :size="16" aria-hidden="true" />
<input
v-model="searchQuery"
type="search"
:placeholder="phone.t('Home.appLibrarySearch')"
@focus="searchFocused = true"
/>
<button
v-if="searchQuery || searchFocused"
type="button"
:aria-label="phone.t('Common.cancel')"
@click="clearSearch"
>
<X :size="15" />
</button>
</div>
<div v-if="searchQuery" class="app-library-results">
<AppIcon
v-for="app in filteredApps"
:key="app.id"
:app="app"
compact
/>
<p v-if="filteredApps.length === 0">{{ phone.t('Home.noApps') }}</p>
</div>
<div v-else class="app-library-groups">
<article
v-for="(group, index) in appGroups"
:key="index"
class="app-library-group"
>
<div>
<AppIcon
v-for="app in group"
:key="app.id"
:app="app"
compact
:show-label="false"
/>
</div>
<span>{{ phone.t(`Home.groups.${index}`) }}</span>
</article>
</div>
</section>
</div>
<Transition name="dock">
<nav
v-if="phone.currentPage === 1"
class="app-dock"
:aria-label="phone.t('Home.dock')"
>
<AppIcon
v-for="app in dockApps"
:key="app.id"
:app="app"
:show-label="false"
/>
</nav>
</Transition>
<nav class="page-indicator" :aria-label="phone.t('Home.pages')">
<button
v-for="page in SPRINGBOARD_PAGE_COUNT"
:key="page"
type="button"
:class="{ active: phone.currentPage === page - 1 }"
:aria-label="`${phone.t('Home.page')} ${page}`"
@click="phone.setCurrentPage(clampPage(page - 1))"
></button>
</nav>
</section>
</template>
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
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 query = ref('')
const tabs = ['today', 'apps', 'games', 'arcade', 'search']
const catalog = [
{
id: 'orbit',
title: 'Orbit',
subtitle: 'Apps.appStore.catalog.orbit',
gradient: 'linear-gradient(135deg,#6f5cff,#29d3c2)',
},
{
id: 'studio',
title: 'Studio',
subtitle: 'Apps.appStore.catalog.studio',
gradient: 'linear-gradient(135deg,#ff8b4a,#ff2d75)',
},
{
id: 'trail',
title: 'Trail',
subtitle: 'Apps.appStore.catalog.trail',
gradient: 'linear-gradient(135deg,#4bd37b,#147ec1)',
},
{
id: 'puzzle',
title: 'Prism',
subtitle: 'Apps.appStore.catalog.prism',
gradient: 'linear-gradient(135deg,#ffd84d,#7b36dc)',
},
]
const shown = computed(() =>
catalog.filter((item) =>
item.title.toLowerCase().includes(query.value.toLowerCase()),
),
)
</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>
<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>
<nav class="app-tabs">
<button
v-for="item in tabs"
:key="item"
:class="{ active: tab === item }"
type="button"
@click="tab = item"
>
{{ phone.t(`Apps.appStore.tabs.${item}`) }}
</button>
</nav>
</main>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { useCalculatorStore } from '@/stores/calculator'
import { usePhoneStore } from '@/stores/phone'
const calculator = useCalculatorStore()
const phone = usePhoneStore()
const keys: Array<{ label: string; action: () => void; kind?: string }> = [
{ label: 'AC', action: calculator.clear, kind: 'utility' },
{ label: '+/', action: calculator.toggleSign, kind: 'utility' },
{ label: '%', action: calculator.percent, kind: 'utility' },
{
label: '÷',
action: () => calculator.chooseOperator('divide'),
kind: 'operator',
},
...['7', '8', '9'].map((value) => ({
label: value,
action: () => calculator.digit(value),
})),
{
label: '×',
action: () => calculator.chooseOperator('multiply'),
kind: 'operator',
},
...['4', '5', '6'].map((value) => ({
label: value,
action: () => calculator.digit(value),
})),
{
label: '',
action: () => calculator.chooseOperator('subtract'),
kind: 'operator',
},
...['1', '2', '3'].map((value) => ({
label: value,
action: () => calculator.digit(value),
})),
{
label: '+',
action: () => calculator.chooseOperator('add'),
kind: 'operator',
},
{ label: '0', action: () => calculator.digit('0'), kind: 'zero' },
{ label: '.', action: calculator.decimal },
{ label: '=', action: calculator.equals, kind: 'operator' },
]
</script>
<template>
<main
class="native-app calculator-app"
:aria-label="phone.t('Apps.calculator.name')"
>
<div class="calculator-display" aria-live="polite">
{{ calculator.display }}
</div>
<div class="calculator-pad">
<button
v-for="key in keys"
:key="key.label"
type="button"
:class="key.kind"
@click="key.action"
>
{{ key.label }}
</button>
</div>
</main>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { Camera, RotateCcw } 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
function capture(): void {
flash.value = true
media.capture()
setTimeout(() => {
flash.value = false
}, 120)
}
</script>
<template>
<main class="native-app camera-app">
<div
class="camera-viewfinder"
:class="`camera-facing--${media.cameraFacing}`"
>
<div class="camera-flash" :class="{ active: flash }" />
<div class="camera-grid" />
<div class="camera-zoom">
<button
v-for="zoom in [0.5, 1, 2]"
:key="zoom"
:class="{ active: media.cameraZoom === zoom }"
type="button"
@click="media.cameraZoom = zoom"
>
{{ zoom }}×
</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>
</main>
</template>
+177
View File
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useClockStore } from '@/stores/clock'
import { usePhoneStore } from '@/stores/phone'
import {
elapsedMilliseconds,
formatStopwatch,
formatTimer,
remainingMilliseconds,
} from '@/utils/clock'
const phone = usePhoneStore()
const clock = useClockStore()
const tab = ref<'world' | 'alarm' | 'stopwatch' | 'timer'>('world')
const now = ref(Date.now())
let ticker: ReturnType<typeof setInterval> | undefined
const stopwatchValue = computed(() =>
elapsedMilliseconds(
clock.stopwatchAccumulated,
clock.stopwatchStartedAt,
now.value,
),
)
const timerValue = computed(() =>
remainingMilliseconds(
clock.timerRemainingAtStart,
clock.timerStartedAt,
now.value,
),
)
const cities = [
{ key: 'cupertino', zone: 'America/Los_Angeles' },
{ key: 'newYork', zone: 'America/New_York' },
{ key: 'london', zone: 'Europe/London' },
{ key: 'tokyo', zone: 'Asia/Tokyo' },
]
const tabs = ['world', 'alarm', 'stopwatch', 'timer'] as const
function cityTime(zone: string): string {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
minute: '2-digit',
timeZone: zone,
}).format(now.value)
}
onMounted(() => {
ticker = setInterval(() => {
now.value = Date.now()
}, 250)
})
onBeforeUnmount(() => clearInterval(ticker))
</script>
<template>
<main class="native-app clock-app">
<header class="app-header">
<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>
</div>
<div v-else-if="tab === 'alarm'" class="settings-list">
<div v-for="alarm in clock.alarms" :key="alarm.id" class="settings-row">
<div>
<strong class="alarm-time">{{ alarm.time }}</strong
><small>{{ phone.t(alarm.labelKey) }}</small>
</div>
<button
class="ios-switch"
:class="{ active: alarm.enabled }"
type="button"
@click="clock.toggleAlarm(alarm.id)"
>
<span />
</button>
</div>
</div>
<div v-else-if="tab === 'stopwatch'" class="clock-tool">
<div class="clock-digits">{{ formatStopwatch(stopwatchValue) }}</div>
<div class="clock-actions">
<button
type="button"
@click="
clock.stopwatchStartedAt === null
? clock.resetStopwatch()
: clock.addLap(now)
"
>
{{
phone.t(
clock.stopwatchStartedAt === null
? 'Common.reset'
: 'Apps.clock.lap',
)
}}</button
><button
class="positive"
type="button"
@click="
clock.stopwatchStartedAt === null
? clock.startStopwatch(now)
: clock.pauseStopwatch(now)
"
>
{{
phone.t(
clock.stopwatchStartedAt === null
? 'Common.start'
: 'Common.stop',
)
}}
</button>
</div>
<div class="lap-list">
<div v-for="(lap, index) in clock.laps" :key="index">
<span
>{{ phone.t('Apps.clock.lap') }}
{{ clock.laps.length - index }}</span
><span>{{ formatStopwatch(lap) }}</span>
</div>
</div>
</div>
<div v-else class="clock-tool">
<div class="timer-ring">
<span>{{ formatTimer(timerValue) }}</span>
</div>
<label class="timer-input"
>{{ phone.t('Apps.clock.minutes')
}}<input
type="number"
min="1"
max="60"
:value="clock.timerDuration / 60000"
@change="
clock.setTimerMinutes(
Number(($event.target as HTMLInputElement).value),
)
"
/></label>
<div class="clock-actions">
<button type="button" @click="clock.resetTimer()">
{{ phone.t('Common.reset') }}</button
><button
class="positive"
type="button"
@click="
clock.timerStartedAt === null
? clock.startTimer(now)
: clock.pauseTimer(now)
"
>
{{
phone.t(
clock.timerStartedAt === null ? 'Common.start' : 'Common.pause',
)
}}
</button>
</div>
</div>
</section>
<nav class="app-tabs">
<button
v-for="item in tabs"
:key="item"
:class="{ active: tab === item }"
type="button"
@click="tab = item"
>
{{ phone.t(`Apps.clock.tabs.${item}`) }}
</button>
</nav>
</main>
</template>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
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 query = ref('')
const tabs = ['library', 'forYou', 'albums', 'search']
const photos = 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 }"
/>
<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>
</section>
<section v-else class="photo-grid">
<article
v-for="photo in photos"
:key="photo.id"
class="photo-tile"
:style="{ background: photo.gradient }"
:aria-label="phone.t(photo.titleKey)"
/>
</section>
<nav class="app-tabs">
<button
v-for="item in tabs"
:key="item"
:class="{ active: tab === item }"
type="button"
@click="tab = item"
>
{{ phone.t(`Apps.photos.tabs.${item}`) }}
</button>
</nav>
</main>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
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()),
),
)
</script>
<template>
<main class="native-app settings-app">
<header class="app-header">
<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>
</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)"
/>
</section>
</main>
</template>
+48 -6
View File
@@ -1,12 +1,54 @@
Locales["en"] = {
CommandDescription = "Open your phone.",
Nui = {
Phone = {
title = "Phone",
readyTitle = "Boilerplate ready",
readyBody = "Konsta UI is connected to the FiveM NUI bridge.",
close = "Close",
Common = {
cancel = "Cancel", close = "Close", home = "Home", pause = "Pause",
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
search = "Search", start = "Start", stop = "Stop",
},
Home = {
appLibrary = "App Library", appLibrarySearch = "Search apps", apps = "Apps",
dock = "Dock", noApps = "No apps found", page = "Page", pages = "Home screen pages",
groups = { ["0"] = "Productivity", ["1"] = "Creativity" },
widgets = {
label = "Widgets",
weather = { city = "Los Santos", condition = "Partly Cloudy" },
calendar = { event = "No more events today" },
battery = { label = "Phone" },
media = { title = "Night Drive", artist = "Sky Radio", play = "Play", pause = "Pause" },
},
},
Apps = {
calculator = { name = "Calculator" },
camera = {
name = "Camera", shutter = "Take photo", flip = "Flip camera",
modes = { video = "Video", photo = "Photo", portrait = "Portrait" },
},
clock = {
name = "Clock", lap = "Lap", minutes = "Minutes",
tabs = { world = "World Clock", alarm = "Alarm", stopwatch = "Stopwatch", timer = "Timer" },
cities = { cupertino = "Cupertino", newYork = "New York", london = "London", tokyo = "Tokyo" },
alarm = { weekday = "Weekdays", weekend = "Weekend" },
},
photos = {
name = "Photos", searchPlaceholder = "Photos, people, places...", recents = "Recents",
favorites = "Favorites", items = "items", memories = "Memories", featured = "City colors",
tabs = { library = "Library", forYou = "For You", albums = "Albums", search = "Search" },
samples = { sunset = "Sunset drive", ocean = "Ocean air", city = "City lights", desert = "Desert road", capture = "Camera capture" },
},
appStore = {
name = "App Store", eyebrow = "Discover", featured = "Featured", heroTitle = "Apps for every day",
heroBody = "Fresh ideas, built for your life in the city.", get = "GET", open = "OPEN",
searchPlaceholder = "Games, Apps, Stories and More",
tabs = { today = "Today", apps = "Apps", games = "Games", arcade = "Arcade", search = "Search" },
catalog = { orbit = "Plan your day", studio = "Create something new", trail = "Explore nearby", prism = "A colorful puzzle" },
},
settings = {
name = "Settings", searchPlaceholder = "Search", wifi = "Wi-Fi", bluetooth = "Bluetooth",
airplaneMode = "Airplane Mode", wallpaper = "Wallpaper",
toggle = { wifi = "Toggle Wi-Fi", bluetooth = "Toggle Bluetooth", airplaneMode = "Toggle Airplane Mode" },
wallpapers = { midnight = "Midnight wallpaper", aurora = "Aurora wallpaper", ember = "Ember wallpaper" },
},
},
},
}
@@ -0,0 +1 @@
import{a as v,u as A,o as l,c as p,b as e,t as s,e as a,g as m,S as y,w as k,v as C,m as w,F as u,r as h,p as z,n as B,h as g,l as V}from"./sky-index-DrcmepK6.js";import{u as $}from"./sky-media-DNqFydRb.js";const x={class:"native-app store-app"},L={class:"app-header"},N={key:0,class:"app-search"},P=["placeholder"],T={class:"store-feature"},D={class:"store-list"},F=["onClick"],M={class:"app-tabs"},q=["onClick"],j=v({__name:"AppStoreApp",setup(E){const o=A(),i=$(),r=g("today"),n=g(""),b=["today","apps","games","arcade","search"],_=[{id:"orbit",title:"Orbit",subtitle:"Apps.appStore.catalog.orbit",gradient:"linear-gradient(135deg,#6f5cff,#29d3c2)"},{id:"studio",title:"Studio",subtitle:"Apps.appStore.catalog.studio",gradient:"linear-gradient(135deg,#ff8b4a,#ff2d75)"},{id:"trail",title:"Trail",subtitle:"Apps.appStore.catalog.trail",gradient:"linear-gradient(135deg,#4bd37b,#147ec1)"},{id:"puzzle",title:"Prism",subtitle:"Apps.appStore.catalog.prism",gradient:"linear-gradient(135deg,#ffd84d,#7b36dc)"}],S=V(()=>_.filter(d=>d.title.toLowerCase().includes(n.value.toLowerCase())));return(d,c)=>(l(),p("main",x,[e("header",L,[e("small",null,s(a(o).t("Apps.appStore.eyebrow")),1),e("h1",null,s(a(o).t(`Apps.appStore.tabs.${r.value}`)),1)]),r.value==="search"?(l(),p("div",N,[m(a(y),{size:17}),k(e("input",{"onUpdate:modelValue":c[0]||(c[0]=t=>n.value=t),placeholder:a(o).t("Apps.appStore.searchPlaceholder")},null,8,P),[[C,n.value]])])):w("",!0),e("section",T,[e("p",null,s(a(o).t("Apps.appStore.featured")),1),e("h2",null,s(a(o).t("Apps.appStore.heroTitle")),1),e("span",null,s(a(o).t("Apps.appStore.heroBody")),1)]),e("section",D,[(l(!0),p(u,null,h(S.value,t=>(l(),p("article",{key:t.id},[e("div",{class:"store-icon",style:z({background:t.gradient})},s(t.title[0]),5),e("div",null,[e("strong",null,s(t.title),1),e("small",null,s(a(o).t(t.subtitle)),1)]),e("button",{type:"button",onClick:f=>a(i).claimApp(t.id)},s(a(o).t(a(i).claimedApps.includes(t.id)?"Apps.appStore.open":"Apps.appStore.get")),9,F)]))),128))]),e("nav",M,[(l(),p(u,null,h(b,t=>e("button",{key:t,class:B({active:r.value===t}),type:"button",onClick:f=>r.value=t},s(a(o).t(`Apps.appStore.tabs.${t}`)),11,q)),64))])]))}});export{j as default};
@@ -0,0 +1 @@
import{d as b,a as g,u as y,o as s,c as u,b as t,t as p,e as d,F as m,r as O,n as h}from"./sky-index-DrcmepK6.js";const _={accumulator:null,display:"0",error:!1,pendingOperator:null,waitingForOperand:!1};function c(r){if(!Number.isFinite(r))return"Error";const n=Number(r.toPrecision(12)),a=String(n);return a.length<=12?a:n.toExponential(6)}function f(r,n,a){return a==="add"?r+n:a==="subtract"?r-n:a==="multiply"?r*n:n===0?null:r/n}function l(){return{..._}}function k(r,n){return/^\d$/.test(n)?r.error||r.waitingForOperand?{...r,display:n,error:!1,waitingForOperand:!1}:r.display==="0"?{...r,display:n}:r.display.replace("-","").replace(".","").length>=10?r:{...r,display:`${r.display}${n}`}:r}function C(r){return r.error||r.waitingForOperand?{...r,display:"0.",error:!1,waitingForOperand:!1}:r.display.includes(".")?r:{...r,display:`${r.display}.`}}function F(r){return r.error||r.display==="0"?r:{...r,display:r.display.startsWith("-")?r.display.slice(1):`-${r.display}`}}function $(r){return r.error?r:{...r,display:c(Number(r.display)/100)}}function S(r,n){if(r.error)return l();const a=Number(r.display);let e=r.accumulator;if(e!==null&&r.pendingOperator&&!r.waitingForOperand){const i=f(e,a,r.pendingOperator);if(i===null)return{...l(),display:"Error",error:!0};e=i}else e===null&&(e=a);return{accumulator:e,display:c(e),error:!1,pendingOperator:n,waitingForOperand:!0}}function w(r){if(r.error||r.accumulator===null||!r.pendingOperator)return r;const n=f(r.accumulator,Number(r.display),r.pendingOperator);return n===null?{...l(),display:"Error",error:!0}:{accumulator:null,display:c(n),error:!1,pendingOperator:null,waitingForOperand:!0}}const N=b("calculator",{state:()=>l(),actions:{chooseOperator(r){Object.assign(this,S(this.$state,r))},clear(){Object.assign(this,l())},decimal(){Object.assign(this,C(this.$state))},digit(r){Object.assign(this,k(this.$state,r))},equals(){Object.assign(this,w(this.$state))},percent(){Object.assign(this,$(this.$state))},toggleSign(){Object.assign(this,F(this.$state))}}}),j=["aria-label"],A={class:"calculator-display","aria-live":"polite"},E={class:"calculator-pad"},L=["onClick"],B=g({__name:"CalculatorApp",setup(r){const n=N(),a=y(),e=[{label:"AC",action:n.clear,kind:"utility"},{label:"+/",action:n.toggleSign,kind:"utility"},{label:"%",action:n.percent,kind:"utility"},{label:"÷",action:()=>n.chooseOperator("divide"),kind:"operator"},...["7","8","9"].map(i=>({label:i,action:()=>n.digit(i)})),{label:"×",action:()=>n.chooseOperator("multiply"),kind:"operator"},...["4","5","6"].map(i=>({label:i,action:()=>n.digit(i)})),{label:"",action:()=>n.chooseOperator("subtract"),kind:"operator"},...["1","2","3"].map(i=>({label:i,action:()=>n.digit(i)})),{label:"+",action:()=>n.chooseOperator("add"),kind:"operator"},{label:"0",action:()=>n.digit("0"),kind:"zero"},{label:".",action:n.decimal},{label:"=",action:n.equals,kind:"operator"}];return(i,T)=>(s(),u("main",{class:"native-app calculator-app","aria-label":d(a).t("Apps.calculator.name")},[t("div",A,p(d(n).display),1),t("div",E,[(s(),u(m,null,O(e,o=>t("button",{key:o.label,type:"button",class:h(o.kind),onClick:o.action},p(o.label),11,L)),64))])],8,j))}});export{B as default};
@@ -0,0 +1 @@
import{u as h}from"./sky-media-DNqFydRb.js";import{f as b,a as k,u as C,o as i,c as l,b as e,n,F as u,r as p,e as a,t as m,g as d,C as y,h as g}from"./sky-index-DrcmepK6.js";const F=b("rotate-ccw",[["path",{d:"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"1357e3"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}]]),M={class:"native-app camera-app"},$={class:"camera-zoom"},A=["onClick"],w={class:"camera-modes"},x=["onClick"],B={class:"camera-controls"},L=["aria-label"],S=["aria-label"],z=k({__name:"CameraApp",setup(N){const s=h(),r=C(),c=g(!1),v=["video","photo","portrait"];function _(){c.value=!0,s.capture(),setTimeout(()=>{c.value=!1},120)}return(V,o)=>(i(),l("main",M,[e("div",{class:n(["camera-viewfinder",`camera-facing--${a(s).cameraFacing}`])},[e("div",{class:n(["camera-flash",{active:c.value}])},null,2),o[1]||(o[1]=e("div",{class:"camera-grid"},null,-1)),e("div",$,[(i(),l(u,null,p([.5,1,2],t=>e("button",{key:t,class:n({active:a(s).cameraZoom===t}),type:"button",onClick:f=>a(s).cameraZoom=t},m(t)+"× ",11,A)),64))])],2),e("div",w,[(i(),l(u,null,p(v,t=>e("button",{key:t,class:n({active:a(s).cameraMode===t}),type:"button",onClick:f=>a(s).cameraMode=t},m(a(r).t(`Apps.camera.modes.${t}`)),11,x)),64))]),e("div",B,[o[2]||(o[2]=e("span",null,null,-1)),e("button",{class:"shutter",type:"button","aria-label":a(r).t("Apps.camera.shutter"),onClick:_},[d(a(y),{size:26})],8,L),e("button",{type:"button","aria-label":a(r).t("Apps.camera.flip"),onClick:o[0]||(o[0]=t=>a(s).cameraFacing=a(s).cameraFacing==="rear"?"front":"rear")},[d(a(F),{size:23})],8,S)])]))}});export{z as default};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{a as y,u as f,o as l,c as n,b as s,t as a,e,g,S as k,w as A,v as C,m as S,p as u,F as h,r as m,n as w,h as _,l as V}from"./sky-index-DrcmepK6.js";import{u as x}from"./sky-media-DNqFydRb.js";const z={class:"native-app photos-app"},B={class:"app-header"},L={key:0,class:"app-search"},N=["placeholder"],P={key:1,class:"album-cards"},$={key:2,class:"featured-photo"},D={key:3,class:"photo-grid"},F=["aria-label"],K={class:"app-tabs"},M=["onClick"],U=y({__name:"PhotosApp",setup(Y){const p=x(),t=f(),r=_("library"),c=_(""),v=["library","forYou","albums","search"],b=V(()=>p.photos.filter(d=>t.t(d.titleKey).toLowerCase().includes(c.value.toLowerCase())));return(d,i)=>(l(),n("main",z,[s("header",B,[s("h1",null,a(e(t).t(`Apps.photos.tabs.${r.value}`)),1)]),r.value==="search"?(l(),n("div",L,[g(e(k),{size:17}),A(s("input",{"onUpdate:modelValue":i[0]||(i[0]=o=>c.value=o),placeholder:e(t).t("Apps.photos.searchPlaceholder")},null,8,N),[[C,c.value]])])):S("",!0),r.value==="albums"?(l(),n("section",P,[s("article",null,[s("div",{class:"photo-tile",style:u({background:e(p).photos[0]?.gradient})},null,4),s("strong",null,a(e(t).t("Apps.photos.recents")),1),s("small",null,a(e(p).photos.length)+" "+a(e(t).t("Apps.photos.items")),1)]),s("article",null,[i[1]||(i[1]=s("div",{class:"photo-tile favorites"},null,-1)),s("strong",null,a(e(t).t("Apps.photos.favorites")),1),s("small",null,"2 "+a(e(t).t("Apps.photos.items")),1)])])):r.value==="forYou"?(l(),n("section",$,[s("p",null,a(e(t).t("Apps.photos.memories")),1),s("div",{style:u({background:e(p).photos[1]?.gradient})},[s("span",null,a(e(t).t("Apps.photos.featured")),1)],4)])):(l(),n("section",D,[(l(!0),n(h,null,m(b.value,o=>(l(),n("article",{key:o.id,class:"photo-tile",style:u({background:o.gradient}),"aria-label":e(t).t(o.titleKey)},null,12,F))),128))])),s("nav",K,[(l(),n(h,null,m(v,o=>s("button",{key:o,class:w({active:r.value===o}),type:"button",onClick:q=>r.value=o},a(e(t).t(`Apps.photos.tabs.${o}`)),11,M)),64))])]))}});export{U as default};
@@ -0,0 +1 @@
import{f as h,a as _,u as f,o as n,c as o,b as a,t as c,e as s,g as m,S as v,w as b,v as A,F as p,r as d,p as C,q as S,s as L,n as u,W as $,h as q,l as M,x}from"./sky-index-DrcmepK6.js";const B=h("bluetooth",[["path",{d:"m7 7 10 10-5 5V2l5 5L7 17",key:"1q5490"}]]);const w=h("radio",[["path",{d:"M16.247 7.761a6 6 0 0 1 0 8.478",key:"1fwjs5"}],["path",{d:"M19.075 4.933a10 10 0 0 1 0 14.134",key:"ehdyv1"}],["path",{d:"M4.925 19.067a10 10 0 0 1 0-14.134",key:"1q22gi"}],["path",{d:"M7.753 16.239a6 6 0 0 1 0-8.478",key:"r2q7qm"}],["circle",{cx:"12",cy:"12",r:"2",key:"1c9p78"}]]),z={class:"native-app settings-app"},D={class:"app-header"},P={class:"app-search"},V=["placeholder"],W={class:"settings-list"},E=["aria-label","onClick"],F={class:"settings-section-title"},I={class:"wallpaper-picker"},N=["aria-label","onClick"],T=_({__name:"SettingsApp",setup(R){const t=f(),i=q(""),y=[{key:"wifi",icon:x,color:"#1677ff"},{key:"bluetooth",icon:B,color:"#1677ff"},{key:"airplaneMode",icon:w,color:"#ff9500"}],k=M(()=>y.filter(r=>t.t(`Apps.settings.${r.key}`).toLowerCase().includes(i.value.toLowerCase())));return(r,l)=>(n(),o("main",z,[a("header",D,[a("h1",null,c(s(t).t("Apps.settings.name")),1)]),a("div",P,[m(s(v),{size:17}),b(a("input",{"onUpdate:modelValue":l[0]||(l[0]=e=>i.value=e),placeholder:s(t).t("Apps.settings.searchPlaceholder")},null,8,V),[[A,i.value]])]),a("section",W,[(n(!0),o(p,null,d(k.value,e=>(n(),o("div",{key:e.key,class:"settings-row"},[a("span",{class:"settings-symbol",style:C({background:e.color})},[(n(),S(L(e.icon),{size:16}))],4),a("span",null,c(s(t).t(`Apps.settings.${e.key}`)),1),a("button",{class:u(["ios-switch",{active:s(t).preferences.settings[e.key]}]),type:"button","aria-label":s(t).t(`Apps.settings.toggle.${e.key}`),onClick:g=>s(t).setSetting(e.key,!s(t).preferences.settings[e.key])},[...l[1]||(l[1]=[a("span",null,null,-1)])],10,E)]))),128))]),a("h2",F,c(s(t).t("Apps.settings.wallpaper")),1),a("section",I,[(n(!0),o(p,null,d(s($),e=>(n(),o("button",{key:e,type:"button",class:u([`wallpaper--${e}`,{active:s(t).preferences.settings.wallpaper===e}]),"aria-label":s(t).t(`Apps.settings.wallpapers.${e}`),onClick:g=>s(t).setWallpaper(e)},null,10,N))),128))])]))}});export{T as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{d as a}from"./sky-index-DrcmepK6.js";const s=[{capturedAt:Date.now()-864e5,gradient:"linear-gradient(145deg, #ff9a62, #5f2c82 58%, #141e30)",id:"sunset-drive",titleKey:"Apps.photos.samples.sunset"},{capturedAt:Date.now()-1728e5,gradient:"linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)",id:"ocean-air",titleKey:"Apps.photos.samples.ocean"},{capturedAt:Date.now()-2592e5,gradient:"linear-gradient(135deg, #fbc2eb, #a6c1ee 48%, #302b63)",id:"city-lights",titleKey:"Apps.photos.samples.city"},{capturedAt:Date.now()-3456e5,gradient:"linear-gradient(150deg, #f6d365, #fda085 45%, #512b58)",id:"desert-road",titleKey:"Apps.photos.samples.desert"}],r=a("media",{state:()=>({cameraFacing:"rear",cameraMode:"photo",cameraZoom:1,captures:[],claimedApps:[]}),getters:{photos:e=>[...e.captures,...s]},actions:{capture(){const e=["linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)","linear-gradient(150deg, #00c9a7, #4d8076 46%, #1f3a5f)","linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)"],t=this.captures.length;this.captures.unshift({capturedAt:Date.now(),gradient:e[t%e.length],id:`capture-${Date.now()}`,titleKey:"Apps.photos.samples.capture"})},claimApp(e){this.claimedApps.includes(e)||this.claimedApps.push(e)}}});export{r as u};
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-D5IGMOYX.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-DMw9emdW.css">
<script type="module" crossorigin src="./assets/sky-index-DrcmepK6.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-BQ2KvS-_.css">
</head>
<body>
<div id="app"></div>