mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +00:00
ADD - homescreen and apps
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+330
@@ -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
@@ -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>
|
||||
|
||||
+928
-50
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user