mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 01:03:25 +00:00
Merge branch 'enhanced-homescreen' into dev
This commit is contained in:
@@ -29,6 +29,7 @@ import { useMessagesStore } from '@/stores/messages'
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { isPhoneAppId } from '@/config/apps'
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
import {
|
||||
@@ -417,6 +418,15 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.params.appId,
|
||||
(appId) => {
|
||||
if (typeof appId === 'string' && isPhoneAppId(appId)) {
|
||||
appStore.recordLaunch(appId)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => notifications.requiresAttention, () => calls.activeCall],
|
||||
([requiresAttention, activeCall]) => {
|
||||
|
||||
@@ -344,13 +344,13 @@ button {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 45px;
|
||||
padding: 11px 26px 0;
|
||||
padding: 11px 42px 0;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 3px #0009;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -583,13 +583,10 @@ button {
|
||||
}
|
||||
}
|
||||
.springboard-track {
|
||||
width: 300%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
touch-action: pan-y;
|
||||
transform: translateX(
|
||||
calc(var(--springboard-page) * -33.333333% + var(--drag-offset))
|
||||
);
|
||||
transform: translateX(calc(var(--springboard-offset) + var(--drag-offset)));
|
||||
transition: transform 0.46s cubic-bezier(0.22, 0.9, 0.24, 1);
|
||||
}
|
||||
.springboard--dragging .springboard-track {
|
||||
@@ -597,7 +594,6 @@ button {
|
||||
}
|
||||
.springboard-page {
|
||||
position: relative;
|
||||
width: 33.333333%;
|
||||
flex: none;
|
||||
padding: 61px 22px 138px;
|
||||
}
|
||||
@@ -858,6 +854,9 @@ button {
|
||||
}
|
||||
.springboard-page--library {
|
||||
padding-top: 62px;
|
||||
padding-bottom: 25px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.app-library-search,
|
||||
.app-search {
|
||||
@@ -886,14 +885,22 @@ button {
|
||||
color: white;
|
||||
}
|
||||
.app-library-groups {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 13px 12px;
|
||||
margin-top: 16px;
|
||||
padding-bottom: 24px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
transition:
|
||||
transform 0.35s ease,
|
||||
opacity 0.35s ease;
|
||||
}
|
||||
.app-library-groups::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.app-library-groups--behind {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.45;
|
||||
@@ -1801,11 +1808,9 @@ button {
|
||||
.store-icon {
|
||||
width: 49px;
|
||||
height: 49px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
flex: 0 0 49px;
|
||||
border-radius: 11px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.store-list article > div:nth-child(2) {
|
||||
display: flex;
|
||||
@@ -1816,13 +1821,31 @@ button {
|
||||
color: #888;
|
||||
}
|
||||
.store-list button {
|
||||
width: 67px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
padding: 5px 15px;
|
||||
padding: 0 12px;
|
||||
background: #222;
|
||||
color: #0a84ff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.store-list button:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
.store-installing {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #0a84ff;
|
||||
}
|
||||
.store-empty {
|
||||
margin: 32px 0;
|
||||
color: #777;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Reference-accurate app surfaces. These remain scoped to the phone resource. */
|
||||
.calculator-app {
|
||||
padding: 54px 16px 27px;
|
||||
@@ -1901,32 +1924,12 @@ button {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.store-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.store-title > div {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 11px;
|
||||
}
|
||||
.store-title h1 {
|
||||
margin: 0;
|
||||
font-size: 34px;
|
||||
}
|
||||
.store-title strong {
|
||||
color: #777;
|
||||
font-size: 18px;
|
||||
}
|
||||
.store-title > span {
|
||||
width: 37px;
|
||||
height: 37px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(145deg, #3a3c46, #1b1c22);
|
||||
}
|
||||
.editorial-card {
|
||||
position: relative;
|
||||
height: 390px;
|
||||
|
||||
@@ -92,6 +92,22 @@ describe('app registry', () => {
|
||||
expect(isPhoneAppId('camera')).toBe(true)
|
||||
expect(isPhoneAppId('photos')).toBe(true)
|
||||
expect(isPhoneAppId('clock')).toBe(true)
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id),
|
||||
).toEqual([
|
||||
'snake',
|
||||
'memory',
|
||||
'number-merge',
|
||||
'minesweeper',
|
||||
'tower-stack',
|
||||
'sky-flappy',
|
||||
'neon-drop',
|
||||
])
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.category === 'social').map(
|
||||
(app) => app.id,
|
||||
),
|
||||
).toEqual(['local-pages', 'phone', 'mail'])
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
|
||||
@@ -54,6 +54,7 @@ import type {
|
||||
|
||||
export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
{
|
||||
category: 'productivity',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/CalendarApp.vue')),
|
||||
),
|
||||
@@ -67,6 +68,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/calendar',
|
||||
},
|
||||
{
|
||||
category: 'social',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/LocalPagesApp.vue')),
|
||||
),
|
||||
@@ -80,6 +82,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/local-pages',
|
||||
},
|
||||
{
|
||||
category: 'social',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/PhoneApp.vue')),
|
||||
),
|
||||
@@ -93,6 +96,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/phone',
|
||||
},
|
||||
{
|
||||
category: 'utilities',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/MessagesApp.vue')),
|
||||
),
|
||||
@@ -119,6 +123,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/map',
|
||||
},
|
||||
{
|
||||
category: 'social',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/MailApp.vue')),
|
||||
),
|
||||
@@ -132,6 +137,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/mail',
|
||||
},
|
||||
{
|
||||
category: 'productivity',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/NotesApp.vue')),
|
||||
),
|
||||
@@ -145,6 +151,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/notes',
|
||||
},
|
||||
{
|
||||
category: 'productivity',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/CalculatorApp.vue')),
|
||||
),
|
||||
@@ -158,6 +165,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/calculator',
|
||||
},
|
||||
{
|
||||
category: 'utilities',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/CameraApp.vue')),
|
||||
),
|
||||
@@ -171,6 +179,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/camera',
|
||||
},
|
||||
{
|
||||
category: 'productivity',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/ClockApp.vue')),
|
||||
),
|
||||
@@ -184,6 +193,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/clock',
|
||||
},
|
||||
{
|
||||
category: 'utilities',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/WeatherApp.vue')),
|
||||
),
|
||||
@@ -197,6 +207,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/weather',
|
||||
},
|
||||
{
|
||||
category: 'utilities',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/GalleryApp.vue')),
|
||||
),
|
||||
@@ -210,6 +221,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/photos',
|
||||
},
|
||||
{
|
||||
category: 'utilities',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/AppStoreApp.vue')),
|
||||
),
|
||||
@@ -223,6 +235,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/app-store',
|
||||
},
|
||||
{
|
||||
category: 'utilities',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/SettingsApp.vue')),
|
||||
),
|
||||
@@ -236,6 +249,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/settings',
|
||||
},
|
||||
{
|
||||
category: 'games',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/SnakeApp.vue')),
|
||||
),
|
||||
@@ -249,6 +263,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/snake',
|
||||
},
|
||||
{
|
||||
category: 'games',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/MemoryApp.vue')),
|
||||
),
|
||||
@@ -262,6 +277,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/memory',
|
||||
},
|
||||
{
|
||||
category: 'games',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/NumberMergeApp.vue')),
|
||||
),
|
||||
@@ -275,6 +291,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/number-merge',
|
||||
},
|
||||
{
|
||||
category: 'games',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/MinesweeperApp.vue')),
|
||||
),
|
||||
@@ -288,6 +305,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/minesweeper',
|
||||
},
|
||||
{
|
||||
category: 'games',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/TowerStackApp.vue')),
|
||||
),
|
||||
@@ -301,6 +319,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/tower-stack',
|
||||
},
|
||||
{
|
||||
category: 'games',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/SkyFlappyApp.vue')),
|
||||
),
|
||||
@@ -314,6 +333,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/sky-flappy',
|
||||
},
|
||||
{
|
||||
category: 'shopping',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/CityMarktApp.vue')),
|
||||
),
|
||||
@@ -327,6 +347,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/citymarkt',
|
||||
},
|
||||
{
|
||||
category: 'games',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/NeonDropApp.vue')),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ saveDeviceNamespace: vi.fn() }))
|
||||
vi.mock('@/stores/phone', () => ({
|
||||
usePhoneStore: () => ({
|
||||
saveDeviceNamespace: mocks.saveDeviceNamespace,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('app store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mocks.saveDeviceNamespace.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('hydrates valid launch counts and persists launches with claimed apps', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({
|
||||
claimedApps: ['snake'],
|
||||
launchCounts: { mail: 3, invalid: 20, phone: -1 },
|
||||
})
|
||||
apps.recordLaunch('mail')
|
||||
|
||||
expect(apps.launchCounts).toEqual({ mail: 4 })
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
|
||||
claimedApps: ['snake'],
|
||||
launchCounts: { mail: 4 },
|
||||
})
|
||||
})
|
||||
|
||||
it('installs an app after showing a three second loading state', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.installApp('snake')
|
||||
|
||||
expect(apps.installingApps.snake).toBe(true)
|
||||
expect(apps.claimedApps).toEqual([])
|
||||
|
||||
vi.advanceTimersByTime(2999)
|
||||
expect(apps.claimedApps).toEqual([])
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(apps.installingApps.snake).toBeUndefined()
|
||||
expect(apps.claimedApps).toEqual(['snake'])
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
|
||||
claimedApps: ['snake'],
|
||||
launchCounts: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores duplicate installation requests and invalid persisted ids', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({ claimedApps: ['memory', 'not-an-app'] })
|
||||
apps.installApp('snake')
|
||||
apps.installApp('snake')
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
expect(apps.claimedApps).toEqual(['memory', 'snake'])
|
||||
expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,27 +1,67 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { isPhoneAppId } from '@/config/apps'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
|
||||
const INSTALL_DURATION_MS = 3000
|
||||
|
||||
export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as string[],
|
||||
claimedApps: [] as LaunchablePhoneAppId[],
|
||||
installingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
|
||||
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
|
||||
}),
|
||||
actions: {
|
||||
claimApp(id: string): void {
|
||||
claimApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.claimedApps.includes(id)) {
|
||||
this.claimedApps.push(id)
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
installApp(id: LaunchablePhoneAppId): void {
|
||||
if (this.claimedApps.includes(id) || this.installingApps[id]) return
|
||||
|
||||
this.installingApps[id] = true
|
||||
globalThis.setTimeout(() => {
|
||||
this.claimApp(id)
|
||||
delete this.installingApps[id]
|
||||
}, INSTALL_DURATION_MS)
|
||||
},
|
||||
hydrate(payload: unknown): void {
|
||||
const data = payload as { claimedApps?: unknown } | null
|
||||
const data = payload as {
|
||||
claimedApps?: unknown
|
||||
launchCounts?: unknown
|
||||
} | null
|
||||
this.claimedApps = Array.isArray(data?.claimedApps)
|
||||
? data.claimedApps.filter((id): id is string => typeof id === 'string')
|
||||
? data.claimedApps.filter(
|
||||
(id): id is LaunchablePhoneAppId =>
|
||||
typeof id === 'string' && isPhoneAppId(id),
|
||||
)
|
||||
: []
|
||||
this.installingApps = {}
|
||||
this.launchCounts = {}
|
||||
if (data?.launchCounts && typeof data.launchCounts === 'object') {
|
||||
for (const [appId, count] of Object.entries(data.launchCounts)) {
|
||||
if (
|
||||
isPhoneAppId(appId) &&
|
||||
typeof count === 'number' &&
|
||||
Number.isFinite(count) &&
|
||||
count > 0
|
||||
) {
|
||||
this.launchCounts[appId] = Math.floor(count)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
recordLaunch(appId: LaunchablePhoneAppId): void {
|
||||
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
|
||||
this.persist()
|
||||
},
|
||||
persist(): void {
|
||||
usePhoneStore().saveDeviceNamespace('apps', {
|
||||
claimedApps: this.claimedApps,
|
||||
launchCounts: this.launchCounts,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@@ -114,42 +114,18 @@ const defaultLocales: LocaleTree = {
|
||||
},
|
||||
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',
|
||||
communityTitle: 'Black Voices and Creators',
|
||||
communityBody: 'Apps and games from the community',
|
||||
playing: "What We're Playing",
|
||||
recommended: 'Recommended for You',
|
||||
selected: 'Great apps selected by our editors',
|
||||
installing: 'Installing',
|
||||
searchPlaceholder: 'Search apps and games',
|
||||
appsTitle: 'Built-in Apps',
|
||||
gamesTitle: 'Games',
|
||||
selected: 'Apps available for your Sky Phone',
|
||||
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',
|
||||
},
|
||||
card: {
|
||||
oneEyebrow: 'App of the Day',
|
||||
oneTitle: 'A universe in your pocket',
|
||||
oneBody: 'Explore something extraordinary today.',
|
||||
twoEyebrow: 'Now Trending',
|
||||
twoTitle: 'Turn up your afternoon',
|
||||
twoBody: 'Fresh sounds and stories picked for you.',
|
||||
threeEyebrow: "Editors' Choice",
|
||||
threeTitle: 'Play without limits',
|
||||
threeBody: 'A new world is waiting.',
|
||||
},
|
||||
},
|
||||
phone: {
|
||||
name: 'Phone',
|
||||
@@ -1018,7 +994,15 @@ const defaultLocales: LocaleTree = {
|
||||
noApps: 'No apps found',
|
||||
page: 'Page',
|
||||
pages: 'Home screen pages',
|
||||
groups: { 0: 'Suggestions', 1: 'Recently Added', other: 'Other' },
|
||||
groups: {
|
||||
suggestions: 'Suggestions',
|
||||
recentlyAdded: 'Recently Added',
|
||||
games: 'Games',
|
||||
productivity: 'Productivity',
|
||||
shopping: 'Shopping',
|
||||
social: 'Social Networks',
|
||||
utilities: 'Utilities',
|
||||
},
|
||||
widgets: {
|
||||
label: 'Widgets',
|
||||
weather: { city: 'Los Santos', condition: 'Partly Cloudy' },
|
||||
@@ -1107,8 +1091,8 @@ export const usePhoneStore = defineStore('phone', {
|
||||
})
|
||||
namespaceQueues.set(namespace, tracked)
|
||||
},
|
||||
setCurrentPage(page: number): void {
|
||||
this.currentPage = clampPage(page)
|
||||
setCurrentPage(page: number, pageCount?: number): void {
|
||||
this.currentPage = clampPage(page, pageCount)
|
||||
},
|
||||
setCameraLandscape(landscape: boolean): void {
|
||||
this.cameraLandscape = landscape
|
||||
|
||||
@@ -26,6 +26,13 @@ export type PhoneAppId =
|
||||
|
||||
export type LaunchablePhoneAppId = PhoneAppId
|
||||
|
||||
export type PhoneAppCategory =
|
||||
| 'games'
|
||||
| 'productivity'
|
||||
| 'shopping'
|
||||
| 'social'
|
||||
| 'utilities'
|
||||
|
||||
export type AppLaunchOrigin = {
|
||||
borderRadius: number
|
||||
scaleX: number
|
||||
@@ -35,6 +42,7 @@ export type AppLaunchOrigin = {
|
||||
}
|
||||
|
||||
export type PhoneAppDefinition = {
|
||||
category: PhoneAppCategory
|
||||
component: Component | null
|
||||
dockOrder: number | null
|
||||
gridOrder: number
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { clampPage } from './pages'
|
||||
import { clampPage, paginateItems } 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)
|
||||
})
|
||||
|
||||
it('splits overflowing app grids into additional pages', () => {
|
||||
expect(
|
||||
paginateItems(
|
||||
Array.from({ length: 21 }, (_, index) => index),
|
||||
20,
|
||||
),
|
||||
).toEqual([
|
||||
Array.from({ length: 20 }, (_, index) => index),
|
||||
[20],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
export const SPRINGBOARD_PAGE_COUNT = 3
|
||||
|
||||
export function paginateItems<T>(items: readonly T[], pageSize: number): T[][] {
|
||||
if (!Number.isInteger(pageSize) || pageSize <= 0) return []
|
||||
const pages: T[][] = []
|
||||
for (let index = 0; index < items.length; index += pageSize) {
|
||||
pages.push(items.slice(index, index + pageSize))
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
export function clampPage(
|
||||
page: number,
|
||||
pageCount = SPRINGBOARD_PAGE_COUNT,
|
||||
|
||||
@@ -5,11 +5,21 @@ import { computed, ref } from 'vue'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import SpringboardWidgets from '@/components/SpringboardWidgets.vue'
|
||||
import { PHONE_APPS } from '@/config/apps'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
import { clampPage, SPRINGBOARD_PAGE_COUNT } from '@/utils/pages'
|
||||
import type { PhoneAppCategory, PhoneAppDefinition } from '@/types/apps'
|
||||
import { paginateItems } from '@/utils/pages'
|
||||
|
||||
const APPS_PER_HOME_PAGE = 20
|
||||
const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
|
||||
'games',
|
||||
'productivity',
|
||||
'social',
|
||||
'utilities',
|
||||
'shopping',
|
||||
]
|
||||
const phone = usePhoneStore()
|
||||
const appStore = useAppStoreStore()
|
||||
const searchQuery = ref('')
|
||||
const searchFocused = ref(false)
|
||||
const showAllApps = ref(false)
|
||||
@@ -19,7 +29,17 @@ let pointerStart = 0
|
||||
let pointerStartedAt = 0
|
||||
|
||||
const gridApps = computed(() =>
|
||||
[...PHONE_APPS].sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
PHONE_APPS.filter(
|
||||
(app) => app.category !== 'games' || appStore.claimedApps.includes(app.id),
|
||||
).sort((a, b) => a.gridOrder - b.gridOrder),
|
||||
)
|
||||
const appPages = computed(() =>
|
||||
paginateItems(gridApps.value, APPS_PER_HOME_PAGE),
|
||||
)
|
||||
const pageCount = computed(() => appPages.value.length + 2)
|
||||
const libraryPage = computed(() => pageCount.value - 1)
|
||||
const isAppPage = computed(
|
||||
() => phone.currentPage > 0 && phone.currentPage < libraryPage.value,
|
||||
)
|
||||
const dockApps = computed(() =>
|
||||
PHONE_APPS.filter((app) => app.dockOrder !== null).sort(
|
||||
@@ -34,14 +54,31 @@ const filteredApps = computed(() => {
|
||||
)
|
||||
})
|
||||
const appGroups = computed(() => {
|
||||
const groups: PhoneAppDefinition[][] = []
|
||||
for (let index = 0; index < gridApps.value.length; index += 3) {
|
||||
groups.push(gridApps.value.slice(index, index + 3))
|
||||
}
|
||||
return groups.map((apps, index) => ({
|
||||
apps,
|
||||
moreApps: groups[(index + 1) % groups.length] ?? [],
|
||||
}))
|
||||
const suggestions = [...gridApps.value]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(appStore.launchCounts[b.id] ?? 0) -
|
||||
(appStore.launchCounts[a.id] ?? 0) || a.gridOrder - b.gridOrder,
|
||||
)
|
||||
.slice(0, 7)
|
||||
const recentlyAdded = [...gridApps.value]
|
||||
.sort((a, b) => b.gridOrder - a.gridOrder)
|
||||
.slice(0, 7)
|
||||
const groups = [
|
||||
{ apps: suggestions, key: 'suggestions' },
|
||||
{ apps: recentlyAdded, key: 'recentlyAdded' },
|
||||
...APP_LIBRARY_CATEGORIES.map((category) => ({
|
||||
apps: gridApps.value.filter((app) => app.category === category),
|
||||
key: category,
|
||||
})),
|
||||
]
|
||||
return groups
|
||||
.filter((group) => group.apps.length > 0)
|
||||
.map((group) => ({
|
||||
...group,
|
||||
apps: group.apps.slice(0, 3),
|
||||
moreApps: group.apps.slice(3),
|
||||
}))
|
||||
})
|
||||
const alphabeticalGroups = computed(() => {
|
||||
const groups: Array<{ apps: PhoneAppDefinition[]; letter: string }> = []
|
||||
@@ -57,8 +94,10 @@ const alphabeticalGroups = computed(() => {
|
||||
})
|
||||
const trackStyle = computed(() => ({
|
||||
'--drag-offset': `${dragOffset.value}px`,
|
||||
'--springboard-page': phone.currentPage,
|
||||
'--springboard-offset': `${(-phone.currentPage * 100) / pageCount.value}%`,
|
||||
width: `${pageCount.value * 100}%`,
|
||||
}))
|
||||
const pageStyle = computed(() => ({ width: `${100 / pageCount.value}%` }))
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
const target = event.target as HTMLElement
|
||||
@@ -80,7 +119,10 @@ function finishPointer(event: PointerEvent): void {
|
||||
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))
|
||||
phone.setCurrentPage(
|
||||
phone.currentPage + (distance < 0 ? 1 : -1),
|
||||
pageCount.value,
|
||||
)
|
||||
}
|
||||
dragging.value = false
|
||||
dragOffset.value = 0
|
||||
@@ -116,22 +158,27 @@ function openAllApps(): void {
|
||||
>
|
||||
<section
|
||||
class="springboard-page springboard-page--widgets"
|
||||
:style="pageStyle"
|
||||
:aria-label="phone.t('Home.widgets.label')"
|
||||
>
|
||||
<SpringboardWidgets />
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-for="(apps, pageIndex) in appPages"
|
||||
:key="`apps-${pageIndex}`"
|
||||
class="springboard-page springboard-page--apps"
|
||||
:style="pageStyle"
|
||||
:aria-label="phone.t('Home.apps')"
|
||||
>
|
||||
<div class="app-grid">
|
||||
<AppIcon v-for="app in gridApps" :key="app.id" :app="app" />
|
||||
<AppIcon v-for="app in apps" :key="app.id" :app="app" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="springboard-page springboard-page--library"
|
||||
:style="pageStyle"
|
||||
:aria-label="phone.t('Home.appLibrary')"
|
||||
>
|
||||
<div
|
||||
@@ -160,8 +207,8 @@ function openAllApps(): void {
|
||||
:class="{ 'app-library-groups--behind': showAllApps }"
|
||||
>
|
||||
<article
|
||||
v-for="(group, index) in appGroups"
|
||||
:key="index"
|
||||
v-for="group in appGroups"
|
||||
:key="group.key"
|
||||
class="app-library-group"
|
||||
>
|
||||
<div class="app-library-group__icons">
|
||||
@@ -173,6 +220,7 @@ function openAllApps(): void {
|
||||
:show-label="false"
|
||||
/>
|
||||
<button
|
||||
v-if="group.moreApps.length"
|
||||
class="app-library-more"
|
||||
type="button"
|
||||
:aria-label="phone.t('Home.allApps')"
|
||||
@@ -187,9 +235,7 @@ function openAllApps(): void {
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<span>{{
|
||||
phone.t(index < 2 ? `Home.groups.${index}` : 'Home.groups.other')
|
||||
}}</span>
|
||||
<span>{{ phone.t(`Home.groups.${group.key}`) }}</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -223,11 +269,7 @@ function openAllApps(): void {
|
||||
</div>
|
||||
|
||||
<Transition name="dock">
|
||||
<nav
|
||||
v-if="phone.currentPage === 1"
|
||||
class="app-dock"
|
||||
:aria-label="phone.t('Home.dock')"
|
||||
>
|
||||
<nav v-if="isAppPage" class="app-dock" :aria-label="phone.t('Home.dock')">
|
||||
<AppIcon
|
||||
v-for="app in dockApps"
|
||||
:key="app.id"
|
||||
@@ -237,14 +279,18 @@ function openAllApps(): void {
|
||||
</nav>
|
||||
</Transition>
|
||||
|
||||
<nav class="page-indicator" :aria-label="phone.t('Home.pages')">
|
||||
<nav
|
||||
v-if="isAppPage && appPages.length > 1"
|
||||
class="page-indicator"
|
||||
:aria-label="phone.t('Home.pages')"
|
||||
>
|
||||
<button
|
||||
v-for="page in SPRINGBOARD_PAGE_COUNT"
|
||||
:key="page"
|
||||
v-for="(_, pageIndex) in appPages"
|
||||
:key="pageIndex"
|
||||
type="button"
|
||||
:class="{ active: phone.currentPage === page - 1 }"
|
||||
:aria-label="`${phone.t('Home.page')} ${page}`"
|
||||
@click="phone.setCurrentPage(clampPage(page - 1))"
|
||||
:class="{ active: phone.currentPage === pageIndex + 1 }"
|
||||
:aria-label="`${phone.t('Home.page')} ${pageIndex + 1}`"
|
||||
@click="phone.setCurrentPage(pageIndex + 1, pageCount)"
|
||||
></button>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
@@ -1,144 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Gamepad2,
|
||||
Grid2X2,
|
||||
Rocket,
|
||||
Search,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
} from 'lucide-vue-next'
|
||||
import { kPreloader } from 'konsta/vue'
|
||||
import { Gamepad2, Grid2X2, Search } from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { PHONE_APPS } from '@/config/apps'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const appStore = useAppStoreStore()
|
||||
const tab = ref<'today' | 'apps' | 'games' | 'arcade' | 'search'>('today')
|
||||
const router = useRouter()
|
||||
const tab = ref<'apps' | 'games' | 'search'>('apps')
|
||||
const query = ref('')
|
||||
const tabs = [
|
||||
{ id: 'today', icon: Sparkles },
|
||||
{ id: 'apps', icon: Grid2X2 },
|
||||
{ id: 'games', icon: Gamepad2 },
|
||||
{ id: 'arcade', icon: Rocket },
|
||||
{ id: 'search', icon: Search },
|
||||
] as const
|
||||
const catalog = [
|
||||
{
|
||||
id: 'orbit',
|
||||
title: 'Orbit',
|
||||
subtitle: 'Apps.appStore.catalog.orbit',
|
||||
gradient: 'linear-gradient(145deg,#453bd1,#75e8ff)',
|
||||
},
|
||||
{
|
||||
id: 'studio',
|
||||
title: 'Studio',
|
||||
subtitle: 'Apps.appStore.catalog.studio',
|
||||
gradient: 'linear-gradient(145deg,#ff8b4a,#ff2d75)',
|
||||
},
|
||||
{
|
||||
id: 'trail',
|
||||
title: 'Trail',
|
||||
subtitle: 'Apps.appStore.catalog.trail',
|
||||
gradient: 'linear-gradient(145deg,#4bd37b,#147ec1)',
|
||||
},
|
||||
{
|
||||
id: 'prism',
|
||||
title: 'Prism',
|
||||
subtitle: 'Apps.appStore.catalog.prism',
|
||||
gradient: 'linear-gradient(145deg,#ffd84d,#7b36dc)',
|
||||
},
|
||||
]
|
||||
const shown = computed(() =>
|
||||
catalog.filter((item) =>
|
||||
item.title.toLowerCase().includes(query.value.toLowerCase()),
|
||||
),
|
||||
const catalog = PHONE_APPS.filter((app) => app.id !== 'app-store').sort(
|
||||
(a, b) => a.gridOrder - b.gridOrder,
|
||||
)
|
||||
const date = new Intl.DateTimeFormat(phone.lang, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
}).format(new Date())
|
||||
const shownApps = computed(() => {
|
||||
if (tab.value === 'games') {
|
||||
return catalog.filter((app) => app.category === 'games')
|
||||
}
|
||||
if (tab.value === 'apps') {
|
||||
return catalog.filter((app) => app.category !== 'games')
|
||||
}
|
||||
|
||||
const search = query.value.trim().toLocaleLowerCase(phone.lang)
|
||||
if (!search) return catalog
|
||||
return catalog.filter((app) =>
|
||||
phone.t(app.labelKey).toLocaleLowerCase(phone.lang).includes(search),
|
||||
)
|
||||
})
|
||||
|
||||
function isInstalled(app: PhoneAppDefinition): boolean {
|
||||
return app.category !== 'games' || appStore.claimedApps.includes(app.id)
|
||||
}
|
||||
|
||||
function handleApp(app: PhoneAppDefinition): void {
|
||||
if (isInstalled(app)) {
|
||||
if (app.route) void router.push(app.route)
|
||||
return
|
||||
}
|
||||
|
||||
appStore.installApp(app.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="native-app reference-store">
|
||||
<section class="store-scroll">
|
||||
<header class="store-title">
|
||||
<div>
|
||||
<h1>{{ phone.t(`Apps.appStore.tabs.${tab}`) }}</h1>
|
||||
<strong v-if="tab === 'today'">{{ date }}</strong>
|
||||
</div>
|
||||
<span><UserRound :size="22" /></span>
|
||||
<h1>{{ phone.t(`Apps.appStore.tabs.${tab}`) }}</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="tab === 'search'" class="app-search">
|
||||
<Search :size="17" /><input
|
||||
<Search :size="17" />
|
||||
<input
|
||||
v-model="query"
|
||||
:placeholder="phone.t('Apps.appStore.searchPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="tab === 'today'">
|
||||
<article class="editorial-card editorial-card--sky">
|
||||
<small>{{ phone.t('Apps.appStore.card.oneEyebrow') }}</small>
|
||||
<h2>{{ phone.t('Apps.appStore.card.oneTitle') }}</h2>
|
||||
<p>{{ phone.t('Apps.appStore.card.oneBody') }}</p>
|
||||
<div class="editorial-orbit">✦</div>
|
||||
</article>
|
||||
<article class="editorial-card editorial-card--music">
|
||||
<small>{{ phone.t('Apps.appStore.card.twoEyebrow') }}</small>
|
||||
<h2>{{ phone.t('Apps.appStore.card.twoTitle') }}</h2>
|
||||
<p>{{ phone.t('Apps.appStore.card.twoBody') }}</p>
|
||||
<div class="editorial-wave" />
|
||||
</article>
|
||||
<div class="store-section-title">
|
||||
<h2>{{ phone.t('Apps.appStore.communityTitle') }}</h2>
|
||||
<p>{{ phone.t('Apps.appStore.communityBody') }}</p>
|
||||
</div>
|
||||
<article class="editorial-card editorial-card--play">
|
||||
<small>{{ phone.t('Apps.appStore.card.threeEyebrow') }}</small>
|
||||
<h2>{{ phone.t('Apps.appStore.card.threeTitle') }}</h2>
|
||||
<p>{{ phone.t('Apps.appStore.card.threeBody') }}</p>
|
||||
</article>
|
||||
</template>
|
||||
<template v-else>
|
||||
<article v-if="tab !== 'search'" class="store-feature">
|
||||
<p>{{ phone.t('Apps.appStore.featured') }}</p>
|
||||
<h2>{{ phone.t('Apps.appStore.heroTitle') }}</h2>
|
||||
<span>{{ phone.t('Apps.appStore.heroBody') }}</span>
|
||||
</article>
|
||||
<div class="store-section-title">
|
||||
<h2>
|
||||
{{
|
||||
phone.t(
|
||||
tab === 'games'
|
||||
? 'Apps.appStore.playing'
|
||||
: 'Apps.appStore.recommended',
|
||||
)
|
||||
}}
|
||||
</h2>
|
||||
<p>{{ phone.t('Apps.appStore.selected') }}</p>
|
||||
</div>
|
||||
<section class="store-list">
|
||||
<article v-for="item in shown" :key="item.id">
|
||||
<div class="store-icon" :style="{ background: item.gradient }">
|
||||
{{ item.title[0] }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ item.title }}</strong
|
||||
><small>{{ phone.t(item.subtitle) }}</small>
|
||||
</div>
|
||||
<button type="button" @click="appStore.claimApp(item.id)">
|
||||
|
||||
<div class="store-section-title">
|
||||
<h2>
|
||||
{{
|
||||
phone.t(
|
||||
tab === 'games'
|
||||
? 'Apps.appStore.gamesTitle'
|
||||
: 'Apps.appStore.appsTitle',
|
||||
)
|
||||
}}
|
||||
</h2>
|
||||
<p>{{ phone.t('Apps.appStore.selected') }}</p>
|
||||
</div>
|
||||
|
||||
<section class="store-list">
|
||||
<article v-for="app in shownApps" :key="app.id">
|
||||
<img
|
||||
class="store-icon"
|
||||
:src="app.iconImage"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
<div>
|
||||
<strong>{{ phone.t(app.labelKey) }}</strong>
|
||||
<small>{{ phone.t(`Home.groups.${app.category}`) }}</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="appStore.installingApps[app.id]"
|
||||
:aria-label="`${phone.t(app.labelKey)} ${phone.t(
|
||||
appStore.installingApps[app.id]
|
||||
? 'Apps.appStore.installing'
|
||||
: isInstalled(app)
|
||||
? 'Apps.appStore.open'
|
||||
: 'Apps.appStore.get',
|
||||
)}`"
|
||||
@click="handleApp(app)"
|
||||
>
|
||||
<k-preloader
|
||||
v-if="appStore.installingApps[app.id]"
|
||||
class="store-installing"
|
||||
/>
|
||||
<template v-else>
|
||||
{{
|
||||
phone.t(
|
||||
appStore.claimedApps.includes(item.id)
|
||||
? 'Apps.appStore.open'
|
||||
: 'Apps.appStore.get',
|
||||
isInstalled(app) ? 'Apps.appStore.open' : 'Apps.appStore.get',
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
</button>
|
||||
</article>
|
||||
<p v-if="shownApps.length === 0" class="store-empty">
|
||||
{{ phone.t('Home.noApps') }}
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<nav class="reference-tabbar">
|
||||
<button
|
||||
v-for="item in tabs"
|
||||
@@ -147,9 +130,8 @@ const date = new Intl.DateTimeFormat(phone.lang, {
|
||||
type="button"
|
||||
@click="tab = item.id"
|
||||
>
|
||||
<component :is="item.icon" :size="21" /><span>{{
|
||||
phone.t(`Apps.appStore.tabs.${item.id}`)
|
||||
}}</span>
|
||||
<component :is="item.icon" :size="21" />
|
||||
<span>{{ phone.t(`Apps.appStore.tabs.${item.id}`) }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</main>
|
||||
|
||||
@@ -414,17 +414,7 @@ const deviceData = {
|
||||
},
|
||||
apps: {
|
||||
payload: {
|
||||
claimedApps: [
|
||||
'snake',
|
||||
'memory',
|
||||
'number-merge',
|
||||
'minesweeper',
|
||||
'tower-stack',
|
||||
'sky-flappy',
|
||||
'neon-drop',
|
||||
'citymarkt',
|
||||
'local-pages',
|
||||
],
|
||||
claimedApps: [],
|
||||
},
|
||||
revision: 2,
|
||||
},
|
||||
|
||||
@@ -26,7 +26,10 @@ Locales["en"] = {
|
||||
Home = {
|
||||
appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps",
|
||||
dock = "Dock", noApps = "No apps found", page = "Page", pages = "Home screen pages",
|
||||
groups = { ["0"] = "Suggestions", ["1"] = "Recently Added", other = "Other" },
|
||||
groups = {
|
||||
suggestions = "Suggestions", recentlyAdded = "Recently Added", games = "Games",
|
||||
productivity = "Productivity", shopping = "Shopping", social = "Social Networks", utilities = "Utilities",
|
||||
},
|
||||
widgets = {
|
||||
label = "Widgets",
|
||||
weather = { city = "Los Santos", condition = "Partly Cloudy" },
|
||||
@@ -408,18 +411,10 @@ Locales["en"] = {
|
||||
},
|
||||
},
|
||||
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",
|
||||
communityTitle = "Black Voices and Creators", communityBody = "Apps and games from the community",
|
||||
playing = "What We're Playing", recommended = "Recommended for You", selected = "Great apps selected by our editors",
|
||||
tabs = { today = "Today", apps = "Apps", games = "Games", arcade = "Arcade", search = "Search" },
|
||||
catalog = { orbit = "Plan your day", studio = "Create something new", trail = "Explore nearby", prism = "A colorful puzzle" },
|
||||
card = {
|
||||
oneEyebrow = "App of the Day", oneTitle = "A universe in your pocket", oneBody = "Explore something extraordinary today.",
|
||||
twoEyebrow = "Now Trending", twoTitle = "Turn up your afternoon", twoBody = "Fresh sounds and stories picked for you.",
|
||||
threeEyebrow = "Editors' Choice", threeTitle = "Play without limits", threeBody = "A new world is waiting.",
|
||||
},
|
||||
name = "App Store", get = "GET", open = "OPEN", installing = "Installing",
|
||||
searchPlaceholder = "Search apps and games", appsTitle = "Built-in Apps", gamesTitle = "Games",
|
||||
selected = "Apps available for your Sky Phone",
|
||||
tabs = { apps = "Apps", games = "Games", search = "Search" },
|
||||
},
|
||||
settings = {
|
||||
name = "Settings", searchPlaceholder = "Search", airplaneMode = "Airplane Mode", streamerMode = "Streamer Mode",
|
||||
|
||||
@@ -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-D8a3rSQd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-BInJdX1o.css">
|
||||
<script type="module" crossorigin src="./assets/sky-index-CWvhdOMN.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-SEnOuL9U.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user