mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-03 11:49:00 +00:00
ADD - connect optional iFruit account
This commit is contained in:
+32
-2
@@ -16,6 +16,10 @@ import PhoneNotifications from '@/components/PhoneNotifications.vue'
|
||||
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
|
||||
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
|
||||
import { useClockStore } from '@/stores/clock'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
import {
|
||||
useNotificationsStore,
|
||||
type PhoneNotificationInput,
|
||||
@@ -36,7 +40,11 @@ const PHONE_BASE_SCALE = 0.69
|
||||
const isDevelopment = import.meta.env.DEV
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
const clock = useClockStore()
|
||||
const mail = useMailStore()
|
||||
const media = useMediaStore()
|
||||
const notes = useNotesStore()
|
||||
const notifications = useNotificationsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -66,9 +74,20 @@ function getViewportScale(): number {
|
||||
return Math.min(window.innerWidth / REFERENCE_VIEWPORT_WIDTH, heightScale)
|
||||
}
|
||||
|
||||
function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
phone.open(payload)
|
||||
account.hydrate(payload.account ?? null)
|
||||
notes.hydrate(payload.notes ?? [])
|
||||
clock.hydrate(payload.device?.data.alarms?.payload)
|
||||
media.hydrate(payload.device?.data.media?.payload)
|
||||
void mail.bootstrap(payload.account?.email ?? '')
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
if (event.data?.type === 'app:open') {
|
||||
phone.open(event.data.data as PhoneOpenPayload)
|
||||
hydratePhone(event.data.data as PhoneOpenPayload)
|
||||
} else if (event.data?.type === 'device:updated') {
|
||||
hydratePhone(event.data.data as PhoneOpenPayload)
|
||||
} else if (event.data?.type === 'app:close') {
|
||||
phone.close()
|
||||
} else if (event.data?.type === 'notification:show' && event.data.data) {
|
||||
@@ -140,7 +159,18 @@ onMounted(() => {
|
||||
})
|
||||
}
|
||||
}, 1000)
|
||||
if (isDevelopment) phone.open()
|
||||
if (isDevelopment) {
|
||||
hydratePhone({
|
||||
account: null,
|
||||
device: {
|
||||
data: {},
|
||||
imei: '356938035643809',
|
||||
name: 'iFruit Phone',
|
||||
},
|
||||
notes: [],
|
||||
token: 'development',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import type { AccountDevice } from '@/types/device'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({
|
||||
nuiCall: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
const devices: AccountDevice[] = [
|
||||
{
|
||||
created_at: '2026-08-04 10:00:00',
|
||||
current: true,
|
||||
device_name: 'iFruit Phone',
|
||||
imei: '356938035643809',
|
||||
updated_at: '2026-08-04 10:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
describe('account store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('links the whole device after account login', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: { devices, email: 'alex@ifruit.com' },
|
||||
success: true,
|
||||
})
|
||||
|
||||
const account = useAccountStore()
|
||||
const response = await account.login('alex', 'roleplay')
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(account.email).toBe('alex@ifruit.com')
|
||||
expect(account.devices).toEqual(devices)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('account:login', {
|
||||
email: 'alex',
|
||||
password: 'roleplay',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the existing account state when credentials fail', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
error: 'invalid_credentials',
|
||||
success: false,
|
||||
})
|
||||
|
||||
const account = useAccountStore()
|
||||
account.hydrate({ devices, email: 'alex@ifruit.com' })
|
||||
await account.login('alex', 'wrong-password')
|
||||
|
||||
expect(account.email).toBe('alex@ifruit.com')
|
||||
expect(account.devices).toEqual(devices)
|
||||
})
|
||||
|
||||
it('clears account state after a factory reset', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ success: true })
|
||||
|
||||
const account = useAccountStore()
|
||||
account.hydrate({ devices, email: 'alex@ifruit.com' })
|
||||
const success = await account.factoryReset()
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(account.email).toBe('')
|
||||
expect(account.devices).toEqual([])
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('device:factory-reset')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { AccountDevice, IfruitAccount } from '@/types/device'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
export const useAccountStore = defineStore('account', {
|
||||
state: () => ({
|
||||
devices: [] as AccountDevice[],
|
||||
email: '',
|
||||
}),
|
||||
actions: {
|
||||
hydrate(account: IfruitAccount | null): void {
|
||||
this.email = account?.email ?? ''
|
||||
this.devices = account?.devices ?? []
|
||||
},
|
||||
async login(email: string, password: string): Promise<NuiResponse<IfruitAccount>> {
|
||||
const response = await nuiCall<IfruitAccount>('account:login', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
if (response.success && response.data) this.hydrate(response.data)
|
||||
return response
|
||||
},
|
||||
async register(email: string, password: string): Promise<NuiResponse<IfruitAccount>> {
|
||||
const response = await nuiCall<IfruitAccount>('account:register', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
if (response.success && response.data) this.hydrate(response.data)
|
||||
return response
|
||||
},
|
||||
async logout(): Promise<boolean> {
|
||||
const response = await nuiCall('account:logout')
|
||||
if (response.success) this.hydrate(null)
|
||||
return response.success
|
||||
},
|
||||
async loadDevices(): Promise<boolean> {
|
||||
const response = await nuiCall<AccountDevice[]>('account:devices')
|
||||
if (response.success && response.data) this.devices = response.data
|
||||
return response.success
|
||||
},
|
||||
async removeDevice(imei: string, password: string): Promise<NuiResponse<AccountDevice[]>> {
|
||||
const response = await nuiCall<AccountDevice[]>('account:remove-device', {
|
||||
imei,
|
||||
password,
|
||||
})
|
||||
if (response.success && response.data) this.devices = response.data
|
||||
return response
|
||||
},
|
||||
async factoryReset(): Promise<boolean> {
|
||||
const response = await nuiCall('device:factory-reset')
|
||||
if (response.success) this.hydrate(null)
|
||||
return response.success
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -5,15 +5,16 @@ import {
|
||||
type Alarm,
|
||||
type AlarmDraft,
|
||||
type AlarmSoundId,
|
||||
DEFAULT_ALARMS,
|
||||
isAlarmDue,
|
||||
readAlarms,
|
||||
writeAlarms,
|
||||
parseAlarms,
|
||||
} from '@/utils/alarms'
|
||||
import { elapsedMilliseconds, remainingMilliseconds } from '@/utils/clock'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
export const useClockStore = defineStore('clock', {
|
||||
state: () => ({
|
||||
alarms: readAlarms(),
|
||||
alarms: structuredClone(DEFAULT_ALARMS),
|
||||
laps: [] as number[],
|
||||
stopwatchAccumulated: 0,
|
||||
stopwatchStartedAt: null as number | null,
|
||||
@@ -81,7 +82,10 @@ export const useClockStore = defineStore('clock', {
|
||||
this.timerStartedAt = null
|
||||
},
|
||||
persistAlarms(): void {
|
||||
writeAlarms(this.alarms)
|
||||
usePhoneStore().saveDeviceNamespace('alarms', this.alarms)
|
||||
},
|
||||
hydrate(alarms: unknown): void {
|
||||
this.alarms = parseAlarms(alarms)
|
||||
},
|
||||
resetStopwatch(): void {
|
||||
this.stopwatchAccumulated = 0
|
||||
|
||||
@@ -88,9 +88,10 @@ describe('mail store', () => {
|
||||
it('logs out the active session and resets mailbox state', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({
|
||||
data: { counts, email: 'alex@ifruit.com' },
|
||||
data: { devices: [], email: 'alex@ifruit.com' },
|
||||
success: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ data: counts, success: true })
|
||||
.mockResolvedValueOnce({
|
||||
data: { hasMore: true, items: [listItem(1)] },
|
||||
success: true,
|
||||
@@ -109,4 +110,23 @@ describe('mail store', () => {
|
||||
expect(mail.items).toEqual([])
|
||||
expect(mail.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('removes loaded cloud mail when the device becomes unlinked', async () => {
|
||||
mockNuiCall
|
||||
.mockResolvedValueOnce({ data: counts, success: true })
|
||||
.mockResolvedValueOnce({
|
||||
data: { hasMore: true, items: [listItem(1)] },
|
||||
success: true,
|
||||
})
|
||||
|
||||
const mail = useMailStore()
|
||||
await mail.bootstrap('alex@ifruit.com')
|
||||
await mail.loadFolder('sent', 'plans')
|
||||
await mail.bootstrap('')
|
||||
|
||||
expect(mail.accountEmail).toBe('')
|
||||
expect(mail.items).toEqual([])
|
||||
expect(mail.folder).toBe('inbox')
|
||||
expect(mail.search).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
+46
-26
@@ -1,6 +1,8 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import type { IfruitAccount } from '@/types/device'
|
||||
import type {
|
||||
MailComposeDraft,
|
||||
MailCounts,
|
||||
@@ -9,7 +11,6 @@ import type {
|
||||
MailListItem,
|
||||
MailListResponse,
|
||||
MailMessage,
|
||||
MailSession,
|
||||
} from '@/types/mail'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
@@ -22,6 +23,7 @@ const emptyCounts = (): MailCounts => ({
|
||||
})
|
||||
|
||||
export const useMailStore = defineStore('mail', () => {
|
||||
const account = useAccountStore()
|
||||
const accountEmail = ref('')
|
||||
const counts = ref<MailCounts>(emptyCounts())
|
||||
const folder = ref<MailFolder>('inbox')
|
||||
@@ -30,31 +32,7 @@ export const useMailStore = defineStore('mail', () => {
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
|
||||
function applySession(session: MailSession): void {
|
||||
accountEmail.value = session.email
|
||||
counts.value = session.counts
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const response = await nuiCall<MailSession>('mail:login', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
if (response.success && response.data) applySession(response.data)
|
||||
return response
|
||||
}
|
||||
|
||||
async function register(email: string, password: string) {
|
||||
const response = await nuiCall<MailSession>('mail:register', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
if (response.success && response.data) applySession(response.data)
|
||||
return response
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
if (accountEmail.value) await nuiCall('mail:logout')
|
||||
function clearSession(): void {
|
||||
accountEmail.value = ''
|
||||
counts.value = emptyCounts()
|
||||
items.value = []
|
||||
@@ -63,6 +41,47 @@ export const useMailStore = defineStore('mail', () => {
|
||||
search.value = ''
|
||||
}
|
||||
|
||||
async function bootstrap(email: string): Promise<void> {
|
||||
if (!email) {
|
||||
clearSession()
|
||||
return
|
||||
}
|
||||
accountEmail.value = email
|
||||
await refreshCounts()
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const response = await nuiCall<IfruitAccount>('mail:login', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
account.hydrate(response.data)
|
||||
await bootstrap(response.data.email)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function register(email: string, password: string) {
|
||||
const response = await nuiCall<IfruitAccount>('mail:register', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
account.hydrate(response.data)
|
||||
await bootstrap(response.data.email)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
if (accountEmail.value) {
|
||||
const response = await nuiCall('mail:logout')
|
||||
if (response.success) account.hydrate(null)
|
||||
}
|
||||
clearSession()
|
||||
}
|
||||
|
||||
async function loadFolder(
|
||||
nextFolder: MailFolder,
|
||||
nextSearch = '',
|
||||
@@ -160,6 +179,7 @@ export const useMailStore = defineStore('mail', () => {
|
||||
|
||||
return {
|
||||
accountEmail,
|
||||
bootstrap,
|
||||
counts,
|
||||
deleteDraft,
|
||||
emptyTrash,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
export type PhonePhoto = {
|
||||
capturedAt: number
|
||||
gradient: string
|
||||
@@ -59,9 +61,29 @@ export const useMediaStore = defineStore('media', {
|
||||
id: `capture-${Date.now()}`,
|
||||
titleKey: 'Apps.photos.samples.capture',
|
||||
})
|
||||
this.persist()
|
||||
},
|
||||
claimApp(id: string): void {
|
||||
if (!this.claimedApps.includes(id)) this.claimedApps.push(id)
|
||||
if (!this.claimedApps.includes(id)) {
|
||||
this.claimedApps.push(id)
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
hydrate(payload: unknown): void {
|
||||
const data = payload as Partial<{
|
||||
captures: PhonePhoto[]
|
||||
claimedApps: string[]
|
||||
}> | null
|
||||
this.captures = Array.isArray(data?.captures) ? data.captures : []
|
||||
this.claimedApps = Array.isArray(data?.claimedApps)
|
||||
? data.claimedApps.filter((id): id is string => typeof id === 'string')
|
||||
: []
|
||||
},
|
||||
persist(): void {
|
||||
usePhoneStore().saveDeviceNamespace('media', {
|
||||
captures: this.captures,
|
||||
claimedApps: this.claimedApps,
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import {
|
||||
type Note,
|
||||
type NoteDraft,
|
||||
readNotes,
|
||||
writeNotes,
|
||||
} from '@/utils/notes'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { type Note, type NoteDraft } from '@/utils/notes'
|
||||
|
||||
export const useNotesStore = defineStore('notes', {
|
||||
state: () => ({
|
||||
notes: readNotes(),
|
||||
notes: [] as Note[],
|
||||
}),
|
||||
actions: {
|
||||
createNote(draft: NoteDraft): Note {
|
||||
@@ -19,30 +15,41 @@ export const useNotesStore = defineStore('notes', {
|
||||
createdAt: now,
|
||||
id: `note-${now}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
pinned: false,
|
||||
revision: 1,
|
||||
updatedAt: now,
|
||||
}
|
||||
this.notes.unshift(note)
|
||||
this.persist()
|
||||
void this.createRemote(note)
|
||||
return note
|
||||
},
|
||||
deleteNote(id: string): void {
|
||||
this.notes = this.notes.filter((note) => note.id !== id)
|
||||
this.persist()
|
||||
void nuiCall<Note[]>('notes:delete', { id }).then((response) => {
|
||||
if (response.success && response.data) this.hydrate(response.data)
|
||||
})
|
||||
},
|
||||
persist(): void {
|
||||
writeNotes(this.notes)
|
||||
hydrate(notes: Note[]): void {
|
||||
this.notes = structuredClone(notes)
|
||||
},
|
||||
async createRemote(note: Note): Promise<void> {
|
||||
const response = await nuiCall<Note[]>('notes:create', note)
|
||||
if (response.data) this.hydrate(response.data)
|
||||
},
|
||||
togglePinned(id: string): void {
|
||||
const note = this.notes.find((candidate) => candidate.id === id)
|
||||
if (!note) return
|
||||
note.pinned = !note.pinned
|
||||
this.persist()
|
||||
void this.updateRemote(note)
|
||||
},
|
||||
updateNote(id: string, draft: NoteDraft): void {
|
||||
const note = this.notes.find((candidate) => candidate.id === id)
|
||||
if (!note) return
|
||||
Object.assign(note, draft, { updatedAt: Date.now() })
|
||||
this.persist()
|
||||
void this.updateRemote(note)
|
||||
},
|
||||
async updateRemote(note: Note): Promise<void> {
|
||||
const response = await nuiCall<Note[]>('notes:update', note)
|
||||
if (response.data) this.hydrate(response.data)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { AppLaunchOrigin, PhoneAppId } from '@/types/apps'
|
||||
import type { DeviceBootstrap, PhoneDevice } from '@/types/device'
|
||||
import { clampPage } from '@/utils/pages'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import {
|
||||
readPhonePreferences,
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
parsePhonePreferences,
|
||||
type AppNotificationPreferences,
|
||||
type PhonePreferencesV1,
|
||||
type WallpaperId,
|
||||
writePhonePreferences,
|
||||
} from '@/utils/preferences'
|
||||
|
||||
type LocaleTree = Record<string, unknown>
|
||||
|
||||
export type PhoneOpenPayload = {
|
||||
account?: DeviceBootstrap['account']
|
||||
device?: PhoneDevice
|
||||
lang?: string
|
||||
locales?: LocaleTree
|
||||
notes?: DeviceBootstrap['notes']
|
||||
token?: string
|
||||
}
|
||||
|
||||
const namespaceQueues = new Map<string, Promise<void>>()
|
||||
|
||||
const defaultLocales: LocaleTree = {
|
||||
Apps: {
|
||||
appStore: {
|
||||
@@ -275,8 +283,11 @@ const defaultLocales: LocaleTree = {
|
||||
on: 'On',
|
||||
off: 'Off',
|
||||
accountName: 'iFruit Account',
|
||||
accountDetail: 'Cloud, Media & Purchases',
|
||||
accountLocalDetail: 'Local account for this phone',
|
||||
accountDetail: 'Cloud, Mail & Notes',
|
||||
accountLocalDetail: 'Not signed in',
|
||||
accountCloudDetail: 'Mail and notes sync through iFruit Cloud',
|
||||
accountLoginBody:
|
||||
'Sign in is optional. This phone also works with local data only.',
|
||||
accountInformation: 'Account Information',
|
||||
accountStatus: 'Account Status',
|
||||
accountStatusValue: 'Active',
|
||||
@@ -310,6 +321,27 @@ const defaultLocales: LocaleTree = {
|
||||
languageValue: 'English',
|
||||
localStorage: 'Local Storage',
|
||||
localStorageValue: 'On Device',
|
||||
deviceInformation: 'Device Information',
|
||||
imei: 'IMEI',
|
||||
linkedDevices: 'Linked Devices',
|
||||
thisDevice: 'This Phone',
|
||||
removeDevice: 'Remove Device',
|
||||
removeDeviceBody:
|
||||
'Enter your iFruit password to remove this device from the account.',
|
||||
signOut: 'Sign Out',
|
||||
factoryReset: 'Erase All Content and Settings',
|
||||
factoryResetBody:
|
||||
'This removes the account and all local data from this phone. Cloud data and the IMEI remain.',
|
||||
accountErrors: {
|
||||
invalid_email: 'Choose a valid 3–32 character iFruit address.',
|
||||
invalid_password: 'Password must be 6–64 characters.',
|
||||
invalid_credentials: 'Email or password is incorrect.',
|
||||
email_taken: 'That iFruit address is already registered.',
|
||||
rate_limited: 'Too many attempts. Try again in a minute.',
|
||||
current_device: 'Sign out instead of removing the current phone.',
|
||||
device_not_found: 'That device is no longer linked.',
|
||||
default: 'The account request failed.',
|
||||
},
|
||||
back: 'Settings',
|
||||
wallpaperPicker: 'Built-in Wallpapers',
|
||||
toggle: {
|
||||
@@ -409,11 +441,13 @@ function getByPath(source: LocaleTree, path: string): unknown {
|
||||
export const usePhoneStore = defineStore('phone', {
|
||||
state: () => ({
|
||||
currentPage: 1,
|
||||
device: null as PhoneDevice | null,
|
||||
deviceRevisions: {} as Record<string, number>,
|
||||
isOpen: false,
|
||||
lang: 'en',
|
||||
launchOrigin: null as AppLaunchOrigin | null,
|
||||
locales: defaultLocales,
|
||||
preferences: readPhonePreferences(),
|
||||
preferences: structuredClone(DEFAULT_PHONE_PREFERENCES),
|
||||
systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
}),
|
||||
getters: {
|
||||
@@ -430,8 +464,39 @@ export const usePhoneStore = defineStore('phone', {
|
||||
open(payload: PhoneOpenPayload = {}): void {
|
||||
this.lang = payload.lang ?? 'en'
|
||||
this.locales = payload.locales ?? defaultLocales
|
||||
if (payload.device) this.hydrateDevice(payload.device)
|
||||
this.isOpen = true
|
||||
},
|
||||
hydrateDevice(device: PhoneDevice): void {
|
||||
this.device = device
|
||||
this.deviceRevisions = Object.fromEntries(
|
||||
Object.entries(device.data).map(([key, value]) => [
|
||||
key,
|
||||
value?.revision ?? 0,
|
||||
]),
|
||||
)
|
||||
this.preferences = parsePhonePreferences(
|
||||
JSON.stringify(device.data.settings?.payload ?? null),
|
||||
)
|
||||
},
|
||||
saveDeviceNamespace(namespace: string, payload: unknown): void {
|
||||
const previous = namespaceQueues.get(namespace) ?? Promise.resolve()
|
||||
const queued = previous.then(async () => {
|
||||
const response = await nuiCall<{ revision: number }>('device:save', {
|
||||
namespace,
|
||||
payload,
|
||||
revision: this.deviceRevisions[namespace] ?? 0,
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
this.deviceRevisions[namespace] = response.data.revision
|
||||
}
|
||||
})
|
||||
const tracked = queued.finally(() => {
|
||||
if (namespaceQueues.get(namespace) === tracked)
|
||||
namespaceQueues.delete(namespace)
|
||||
})
|
||||
namespaceQueues.set(namespace, tracked)
|
||||
},
|
||||
setCurrentPage(page: number): void {
|
||||
this.currentPage = clampPage(page)
|
||||
},
|
||||
@@ -444,21 +509,21 @@ export const usePhoneStore = defineStore('phone', {
|
||||
value: boolean,
|
||||
): void {
|
||||
this.preferences.settings.notifications[appId][key] = value
|
||||
writePhonePreferences(this.preferences)
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
},
|
||||
setPreference<K extends keyof PhonePreferencesV1['settings']>(
|
||||
key: K,
|
||||
value: PhonePreferencesV1['settings'][K],
|
||||
): void {
|
||||
this.preferences.settings[key] = value
|
||||
writePhonePreferences(this.preferences)
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
},
|
||||
setSystemDarkMode(value: boolean): void {
|
||||
this.systemDarkMode = value
|
||||
},
|
||||
setWallpaper(wallpaper: WallpaperId): void {
|
||||
this.preferences.settings.wallpaper = wallpaper
|
||||
writePhonePreferences(this.preferences)
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
},
|
||||
t(path: string, replacements: Record<string, string> = {}): string {
|
||||
const translated = getByPath(this.locales, path)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Note } from '@/utils/notes'
|
||||
|
||||
export type DeviceDataEntry<T = unknown> = {
|
||||
payload: T
|
||||
revision: number
|
||||
}
|
||||
|
||||
export type PhoneDevice = {
|
||||
data: Record<string, DeviceDataEntry | undefined>
|
||||
imei: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type AccountDevice = {
|
||||
created_at: string
|
||||
current: boolean
|
||||
device_name: string
|
||||
imei: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type IfruitAccount = {
|
||||
devices: AccountDevice[]
|
||||
email: string
|
||||
id?: number
|
||||
}
|
||||
|
||||
export type DeviceBootstrap = {
|
||||
account: IfruitAccount | null
|
||||
device: PhoneDevice
|
||||
notes: Note[]
|
||||
token: string
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
export const ALARMS_STORAGE_KEY = 'sky_phone.clock.alarms.v1'
|
||||
|
||||
export const ALARM_SOUND_IDS = [
|
||||
'radar',
|
||||
'beacon',
|
||||
@@ -27,7 +25,7 @@ export type Alarm = {
|
||||
|
||||
export type AlarmDraft = Pick<Alarm, 'note' | 'sound' | 'time' | 'weekdays'>
|
||||
|
||||
const DEFAULT_ALARMS: Alarm[] = [
|
||||
export const DEFAULT_ALARMS: Alarm[] = [
|
||||
{
|
||||
enabled: true,
|
||||
id: 'weekday',
|
||||
@@ -90,21 +88,9 @@ function readAlarm(value: unknown): Alarm | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function readAlarms(): Alarm[] {
|
||||
const raw = window.localStorage.getItem(ALARMS_STORAGE_KEY)
|
||||
if (!raw) return structuredClone(DEFAULT_ALARMS)
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return structuredClone(DEFAULT_ALARMS)
|
||||
return parsed.map(readAlarm).filter((alarm): alarm is Alarm => !!alarm)
|
||||
} catch {
|
||||
return structuredClone(DEFAULT_ALARMS)
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAlarms(alarms: Alarm[]): void {
|
||||
window.localStorage.setItem(ALARMS_STORAGE_KEY, JSON.stringify(alarms))
|
||||
export function parseAlarms(value: unknown): Alarm[] {
|
||||
if (!Array.isArray(value)) return structuredClone(DEFAULT_ALARMS)
|
||||
return value.map(readAlarm).filter((alarm): alarm is Alarm => !!alarm)
|
||||
}
|
||||
|
||||
export function alarmMinuteKey(date: Date): string {
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('notes persistence', () => {
|
||||
}
|
||||
|
||||
expect(parseNotes(JSON.stringify([valid, { id: 'broken' }]))).toEqual([
|
||||
valid,
|
||||
{ ...valid, revision: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
export const NOTES_STORAGE_KEY = 'sky_phone.notes.v1'
|
||||
|
||||
export type Note = {
|
||||
body: string
|
||||
createdAt: number
|
||||
id: string
|
||||
pinned: boolean
|
||||
revision: number
|
||||
title: string
|
||||
updatedAt: number
|
||||
}
|
||||
@@ -21,6 +20,8 @@ function isNote(value: unknown): value is Note {
|
||||
typeof note.id === 'string' &&
|
||||
Boolean(note.id) &&
|
||||
typeof note.pinned === 'boolean' &&
|
||||
(note.revision === undefined ||
|
||||
(typeof note.revision === 'number' && Number.isFinite(note.revision))) &&
|
||||
typeof note.title === 'string' &&
|
||||
typeof note.updatedAt === 'number' &&
|
||||
Number.isFinite(note.updatedAt)
|
||||
@@ -33,16 +34,11 @@ export function parseNotes(raw: string | null): Note[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter(isNote)
|
||||
return parsed.filter(isNote).map((note) => ({
|
||||
...note,
|
||||
revision: note.revision ?? 1,
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function readNotes(): Note[] {
|
||||
return parseNotes(window.localStorage.getItem(NOTES_STORAGE_KEY))
|
||||
}
|
||||
|
||||
export function writeNotes(notes: Note[]): void {
|
||||
window.localStorage.setItem(NOTES_STORAGE_KEY, JSON.stringify(notes))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { PhoneAppId } from '@/types/apps'
|
||||
|
||||
export const PHONE_PREFERENCES_KEY = 'sky_phone.preferences.v1'
|
||||
|
||||
export const APPEARANCE_MODE_IDS = ['automatic', 'light', 'dark'] as const
|
||||
export const PHONE_FRAME_IDS = [
|
||||
'black',
|
||||
@@ -197,16 +195,3 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -361,9 +361,7 @@ onBeforeUnmount(() => {
|
||||
if (draftTimer) clearTimeout(draftTimer)
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
if (screen.value === 'compose') {
|
||||
void saveDraftNow().finally(() => mail.logout())
|
||||
} else {
|
||||
void mail.logout()
|
||||
void saveDraftNow()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
kBlock,
|
||||
kBlockTitle,
|
||||
kButton,
|
||||
kDialog,
|
||||
kDialogButton,
|
||||
kList,
|
||||
kListButton,
|
||||
kListInput,
|
||||
kListItem,
|
||||
kNavbar,
|
||||
kNavbarBackLink,
|
||||
kPage,
|
||||
kRange,
|
||||
kSearchbar,
|
||||
kToast,
|
||||
kToggle,
|
||||
} from 'konsta/vue'
|
||||
import {
|
||||
@@ -16,7 +23,9 @@ import {
|
||||
EyeOff,
|
||||
Monitor,
|
||||
Plane,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Smartphone,
|
||||
Sun,
|
||||
UserRound,
|
||||
Volume2,
|
||||
@@ -26,6 +35,7 @@ import { computed, nextTick, ref, type ComponentPublicInstance } from 'vue'
|
||||
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
|
||||
import { PHONE_APPS } from '@/config/apps'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
|
||||
import {
|
||||
APPEARANCE_MODE_IDS,
|
||||
@@ -55,10 +65,21 @@ type RootToggleKey = 'airplaneMode' | 'streamerMode'
|
||||
type SubmenuView = Exclude<SettingsView, 'root' | 'notification-detail'>
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
const query = ref('')
|
||||
const activeView = ref<SettingsView>('root')
|
||||
const selectedNotificationAppId = ref<PhoneAppId>('calculator')
|
||||
const settingsPage = ref<ComponentPublicInstance | null>(null)
|
||||
const accountMode = ref<'login' | 'register'>('login')
|
||||
const accountEmail = ref('')
|
||||
const accountPassword = ref('')
|
||||
const accountConfirm = ref('')
|
||||
const accountSubmitting = ref(false)
|
||||
const accountToast = ref('')
|
||||
const removeDeviceImei = ref('')
|
||||
const removeDevicePassword = ref('')
|
||||
const removeDeviceOpened = ref(false)
|
||||
const resetOpened = ref(false)
|
||||
|
||||
const toggleRows = [
|
||||
{
|
||||
@@ -208,6 +229,70 @@ function selectRingtone(ringtone: RingtoneId): void {
|
||||
function selectNotificationSound(sound: NotificationSoundId): void {
|
||||
phone.setPreference('notificationSound', sound)
|
||||
}
|
||||
|
||||
function eventValue(event: Event): string {
|
||||
return (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
function accountError(error?: string): string {
|
||||
const known = [
|
||||
'invalid_email',
|
||||
'invalid_password',
|
||||
'invalid_credentials',
|
||||
'email_taken',
|
||||
'rate_limited',
|
||||
'current_device',
|
||||
'device_not_found',
|
||||
]
|
||||
return phone.t(
|
||||
`Apps.settings.accountErrors.${error && known.includes(error) ? error : 'default'}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function submitAccount(): Promise<void> {
|
||||
if (
|
||||
accountMode.value === 'register' &&
|
||||
accountPassword.value !== accountConfirm.value
|
||||
) {
|
||||
accountToast.value = phone.t('Apps.mail.passwordsMismatch')
|
||||
return
|
||||
}
|
||||
accountSubmitting.value = true
|
||||
const response =
|
||||
accountMode.value === 'login'
|
||||
? await account.login(accountEmail.value, accountPassword.value)
|
||||
: await account.register(accountEmail.value, accountPassword.value)
|
||||
accountSubmitting.value = false
|
||||
if (!response.success) accountToast.value = accountError(response.error)
|
||||
else {
|
||||
accountPassword.value = ''
|
||||
accountConfirm.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutAccount(): Promise<void> {
|
||||
if (!(await account.logout())) accountToast.value = accountError()
|
||||
}
|
||||
|
||||
function requestRemoveDevice(imei: string): void {
|
||||
removeDeviceImei.value = imei
|
||||
removeDevicePassword.value = ''
|
||||
removeDeviceOpened.value = true
|
||||
}
|
||||
|
||||
async function confirmRemoveDevice(): Promise<void> {
|
||||
const response = await account.removeDevice(
|
||||
removeDeviceImei.value,
|
||||
removeDevicePassword.value,
|
||||
)
|
||||
if (!response.success) accountToast.value = accountError(response.error)
|
||||
else removeDeviceOpened.value = false
|
||||
}
|
||||
|
||||
async function confirmFactoryReset(): Promise<void> {
|
||||
resetOpened.value = false
|
||||
if (!(await account.factoryReset())) accountToast.value = accountError()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -331,34 +416,129 @@ function selectNotificationSound(sound: NotificationSoundId): void {
|
||||
</k-navbar>
|
||||
|
||||
<template v-if="activeView === 'account'">
|
||||
<k-list strong inset>
|
||||
<template v-if="!account.email">
|
||||
<k-block-title>
|
||||
{{
|
||||
phone.t(
|
||||
accountMode === 'login'
|
||||
? 'Apps.settings.accountLoginBody'
|
||||
: 'Apps.mail.passwordWarning',
|
||||
)
|
||||
}}
|
||||
</k-block-title>
|
||||
<k-list>
|
||||
<k-list-input
|
||||
:value="accountEmail"
|
||||
:label="phone.t('Apps.mail.email')"
|
||||
outline
|
||||
floating-label
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
@input="accountEmail = eventValue($event)"
|
||||
/>
|
||||
<k-list-input
|
||||
type="password"
|
||||
:value="accountPassword"
|
||||
:label="phone.t('Apps.mail.password')"
|
||||
outline
|
||||
floating-label
|
||||
@input="accountPassword = eventValue($event)"
|
||||
/>
|
||||
<k-list-input
|
||||
v-if="accountMode === 'register'"
|
||||
type="password"
|
||||
:value="accountConfirm"
|
||||
:label="phone.t('Apps.mail.confirmPassword')"
|
||||
outline
|
||||
floating-label
|
||||
@input="accountConfirm = eventValue($event)"
|
||||
/>
|
||||
</k-list>
|
||||
<k-block>
|
||||
<k-button large rounded :disabled="accountSubmitting" @click="submitAccount">
|
||||
{{
|
||||
phone.t(
|
||||
accountMode === 'login'
|
||||
? 'Apps.mail.login'
|
||||
: 'Apps.mail.register',
|
||||
)
|
||||
}}
|
||||
</k-button>
|
||||
</k-block>
|
||||
<k-list strong inset>
|
||||
<k-list-button
|
||||
@click="accountMode = accountMode === 'login' ? 'register' : 'login'"
|
||||
>
|
||||
{{
|
||||
phone.t(
|
||||
accountMode === 'login'
|
||||
? 'Apps.mail.register'
|
||||
: 'Apps.mail.login',
|
||||
)
|
||||
}}
|
||||
</k-list-button>
|
||||
</k-list>
|
||||
<k-block-title>{{ phone.t('Apps.settings.deviceInformation') }}</k-block-title>
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
:title="phone.t('Apps.settings.imei')"
|
||||
:after="phone.device?.imei ?? '—'"
|
||||
/>
|
||||
<k-list-button @click="resetOpened = true">
|
||||
<RotateCcw :size="18" />
|
||||
{{ phone.t('Apps.settings.factoryReset') }}
|
||||
</k-list-button>
|
||||
</k-list>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
:title="phone.t('Apps.settings.accountName')"
|
||||
:subtitle="phone.t('Apps.settings.accountLocalDetail')"
|
||||
:title="account.email"
|
||||
:subtitle="phone.t('Apps.settings.accountCloudDetail')"
|
||||
>
|
||||
<template #media>
|
||||
<UserRound class="w-12 h-12 text-primary" />
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
</k-list>
|
||||
|
||||
<k-block-title>
|
||||
{{ phone.t('Apps.settings.accountInformation') }}
|
||||
</k-block-title>
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
:title="phone.t('Apps.settings.accountStatus')"
|
||||
:after="phone.t('Apps.settings.accountStatusValue')"
|
||||
/>
|
||||
<k-list-item
|
||||
:title="phone.t('Apps.settings.accountStorage')"
|
||||
:after="phone.t('Apps.settings.accountStorageValue')"
|
||||
/>
|
||||
<k-list-item
|
||||
:title="phone.t('Apps.settings.accountPurchases')"
|
||||
:after="phone.t('Apps.settings.accountPurchasesValue')"
|
||||
/>
|
||||
</k-list>
|
||||
<k-block-title>{{ phone.t('Apps.settings.deviceInformation') }}</k-block-title>
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
:title="phone.t('Apps.settings.imei')"
|
||||
:after="phone.device?.imei ?? '—'"
|
||||
/>
|
||||
</k-list>
|
||||
|
||||
<k-block-title>{{ phone.t('Apps.settings.linkedDevices') }}</k-block-title>
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
v-for="device in account.devices"
|
||||
:key="device.imei"
|
||||
:title="device.device_name"
|
||||
:subtitle="device.imei"
|
||||
:after="device.current ? phone.t('Apps.settings.thisDevice') : undefined"
|
||||
>
|
||||
<template #media><Smartphone :size="22" /></template>
|
||||
<template v-if="!device.current" #footer>
|
||||
<k-list-button @click="requestRemoveDevice(device.imei)">
|
||||
{{ phone.t('Apps.settings.removeDevice') }}
|
||||
</k-list-button>
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
|
||||
<k-list strong inset>
|
||||
<k-list-button @click="logoutAccount">
|
||||
{{ phone.t('Apps.settings.signOut') }}
|
||||
</k-list-button>
|
||||
<k-list-button @click="resetOpened = true">
|
||||
<RotateCcw :size="18" />
|
||||
{{ phone.t('Apps.settings.factoryReset') }}
|
||||
</k-list-button>
|
||||
</k-list>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeView === 'notifications'">
|
||||
@@ -689,4 +869,51 @@ function selectNotificationSound(sound: NotificationSoundId): void {
|
||||
</template>
|
||||
</template>
|
||||
</k-page>
|
||||
|
||||
<k-dialog
|
||||
:opened="removeDeviceOpened"
|
||||
:title="phone.t('Apps.settings.removeDevice')"
|
||||
:content="phone.t('Apps.settings.removeDeviceBody')"
|
||||
@backdropclick="removeDeviceOpened = false"
|
||||
>
|
||||
<k-list>
|
||||
<k-list-input
|
||||
type="password"
|
||||
:value="removeDevicePassword"
|
||||
:label="phone.t('Apps.mail.password')"
|
||||
@input="removeDevicePassword = eventValue($event)"
|
||||
/>
|
||||
</k-list>
|
||||
<template #buttons>
|
||||
<k-dialog-button @click="removeDeviceOpened = false">
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</k-dialog-button>
|
||||
<k-dialog-button strong @click="confirmRemoveDevice">
|
||||
{{ phone.t('Apps.settings.removeDevice') }}
|
||||
</k-dialog-button>
|
||||
</template>
|
||||
</k-dialog>
|
||||
|
||||
<k-dialog
|
||||
:opened="resetOpened"
|
||||
:title="phone.t('Apps.settings.factoryReset')"
|
||||
:content="phone.t('Apps.settings.factoryResetBody')"
|
||||
@backdropclick="resetOpened = false"
|
||||
>
|
||||
<template #buttons>
|
||||
<k-dialog-button @click="resetOpened = false">
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</k-dialog-button>
|
||||
<k-dialog-button strong @click="confirmFactoryReset">
|
||||
{{ phone.t('Common.reset') }}
|
||||
</k-dialog-button>
|
||||
</template>
|
||||
</k-dialog>
|
||||
|
||||
<k-toast
|
||||
:opened="Boolean(accountToast)"
|
||||
position="center"
|
||||
:text="accountToast"
|
||||
@click="accountToast = ''"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -9,6 +9,18 @@ app.use(express.json())
|
||||
|
||||
let authenticated = false
|
||||
let draft = null
|
||||
let linkedAccount = null
|
||||
let mockNotes = []
|
||||
const deviceData = {}
|
||||
const accountDevices = [
|
||||
{
|
||||
created_at: '2026-08-04 12:00:00',
|
||||
current: true,
|
||||
device_name: 'iFruit Phone',
|
||||
imei: '356938035643809',
|
||||
updated_at: '2026-08-04 12:00:00',
|
||||
},
|
||||
]
|
||||
const messages = [
|
||||
{
|
||||
body: 'Welcome to iFruit Mail. Your shared mailbox is ready to use.',
|
||||
@@ -82,12 +94,82 @@ function counts() {
|
||||
app.post('/api/:endpoint', (request, response) => {
|
||||
console.log(`[NUI] ${request.params.endpoint}`, request.body)
|
||||
const endpoint = request.params.endpoint
|
||||
if (endpoint === 'account:login' || endpoint === 'account:register') {
|
||||
authenticated = true
|
||||
linkedAccount = {
|
||||
devices: accountDevices,
|
||||
email: request.body.email.includes('@')
|
||||
? request.body.email
|
||||
: `${request.body.email}@ifruit.com`,
|
||||
}
|
||||
response.json({ success: true, data: linkedAccount })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'account:logout') {
|
||||
authenticated = false
|
||||
linkedAccount = null
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'account:devices') {
|
||||
response.json({ success: true, data: accountDevices })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'account:remove-device') {
|
||||
response.json({ success: true, data: accountDevices })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'device:save') {
|
||||
const current = deviceData[request.body.namespace]
|
||||
const revision = (current?.revision ?? 0) + 1
|
||||
deviceData[request.body.namespace] = {
|
||||
payload: request.body.payload,
|
||||
revision,
|
||||
}
|
||||
response.json({ success: true, data: { revision } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'device:factory-reset') {
|
||||
authenticated = false
|
||||
linkedAccount = null
|
||||
mockNotes = []
|
||||
for (const key of Object.keys(deviceData)) delete deviceData[key]
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'notes:list') {
|
||||
response.json({ success: true, data: mockNotes })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'notes:create') {
|
||||
mockNotes.unshift({ ...request.body, revision: 1 })
|
||||
response.json({ success: true, data: mockNotes })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'notes:update') {
|
||||
const index = mockNotes.findIndex((note) => note.id === request.body.id)
|
||||
if (index >= 0) {
|
||||
mockNotes[index] = {
|
||||
...request.body,
|
||||
revision: mockNotes[index].revision + 1,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
response.json({ success: true, data: mockNotes })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'notes:delete') {
|
||||
mockNotes = mockNotes.filter((note) => note.id !== request.body.id)
|
||||
response.json({ success: true, data: mockNotes })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'mail:login' || endpoint === 'mail:register') {
|
||||
authenticated = true
|
||||
response.json({
|
||||
success: true,
|
||||
data: { counts: counts(), email: 'demo@ifruit.com' },
|
||||
})
|
||||
linkedAccount = {
|
||||
devices: accountDevices,
|
||||
email: 'demo@ifruit.com',
|
||||
}
|
||||
response.json({ success: true, data: linkedAccount })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'mail:logout') {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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-BW7SWjn6.js"></script>
|
||||
<script type="module" crossorigin src="./assets/sky-index-CN9A-zsO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-CFphfAe0.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user