mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 08:13:23 +00:00
Merge branch 'dev' into feature/funk
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# sky_phone
|
||||
|
||||
Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `sky_phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly.
|
||||
Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly.
|
||||
|
||||
An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, notes, contacts, and recent calls. Linking from Mail or Settings moves local data into an empty cloud account; an existing cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting cloud data.
|
||||
|
||||
@@ -8,7 +8,7 @@ An iFruit account is optional. Unlinked devices retain local settings, alarms, m
|
||||
|
||||
- ESX Legacy (`es_extended`), Qbox (`qbx_core`), or QBCore (`qb-core`). The bridge selects a running supported framework when `Config.Bridge.Framework` is set to `"auto"`.
|
||||
- A supported inventory: `ox_inventory`, `qb-inventory`, `lj-inventory`, `qs-inventory`, `codem-inventory`, `core_inventory`, `mf-inventory`, or `smx-inventory`. The bridge auto-detects a running provider and normalizes metadata, slots, counts, item mutations, and usable-item callbacks. `mf-inventory` and `smx-inventory` require ESX. Because SMX stores standard ESX items as stacks, its adapter persists one active Phone/SIM metadata record per player and item type in ESX player metadata.
|
||||
- A non-stackable inventory item named `sky_phone`.
|
||||
- A non-stackable inventory item named `phone`.
|
||||
- Two unique, non-stackable inventory items named `sky_phone_sim_registered` and `sky_phone_sim_anonymous`. Their metadata is initialized automatically on first use, so shops and crafting recipes add plain items without supplying a number.
|
||||
- `oxmysql` with MySQL/MariaDB.
|
||||
- `pma-voice` when `Config.Calls.VoiceProvider` is set to `"pma"`.
|
||||
@@ -58,7 +58,7 @@ For `ox_inventory`, configure all three items with `stack = false` and `consume
|
||||
Example `ox_inventory/data/items.lua` entries:
|
||||
|
||||
```lua
|
||||
["sky_phone"] = {
|
||||
["phone"] = {
|
||||
label = "iFruit Phone",
|
||||
weight = 200,
|
||||
stack = false,
|
||||
|
||||
+141
-9
@@ -14,6 +14,7 @@ import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
|
||||
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
|
||||
import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue'
|
||||
import PhoneLockScreen from '@/components/PhoneLockScreen.vue'
|
||||
import PhonePasscode from '@/components/PhonePasscode.vue'
|
||||
import PhoneNotifications from '@/components/PhoneNotifications.vue'
|
||||
import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue'
|
||||
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
|
||||
@@ -33,6 +34,7 @@ import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { useWidgetsStore } from '@/stores/widgets'
|
||||
import { isPhoneAppId } from '@/config/apps'
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
@@ -131,6 +133,7 @@ const darkchat = useDarkChatStore()
|
||||
const media = useMediaStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
const appStore = useAppStoreStore()
|
||||
const widgets = useWidgetsStore()
|
||||
const notes = useNotesStore()
|
||||
const weather = useWeatherStore()
|
||||
const notifications = useNotificationsStore()
|
||||
@@ -142,13 +145,21 @@ const appTransitionName = computed(() =>
|
||||
)
|
||||
const isLocked = ref(false)
|
||||
const isUnlocking = ref(false)
|
||||
const passcodeBusy = ref(false)
|
||||
const passcodeError = ref('')
|
||||
const passcodeResetKey = ref(0)
|
||||
const passcodeRetrySeconds = ref(0)
|
||||
const passcodeVisible = ref(false)
|
||||
const pendingUnlockRoute = ref<string | null>(null)
|
||||
const unlockedServicesLoaded = ref(false)
|
||||
const controlCenterOpened = ref(false)
|
||||
const simPicker = ref<SimPickerPayload | null>(null)
|
||||
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const viewportScale = ref(getViewportScale())
|
||||
const phoneBaseZoom = computed(() =>
|
||||
viewportScale.value *
|
||||
(isDevelopment ? DEVELOPMENT_PHONE_SCALE : PHONE_BASE_SCALE),
|
||||
const phoneBaseZoom = computed(
|
||||
() =>
|
||||
viewportScale.value *
|
||||
(isDevelopment ? DEVELOPMENT_PHONE_SCALE : PHONE_BASE_SCALE),
|
||||
)
|
||||
const phoneResolutionStyle = computed<CSSProperties>(() => ({
|
||||
'--phone-edge-gap': `${24 * viewportScale.value}px`,
|
||||
@@ -166,6 +177,7 @@ const phoneFrameImage = computed(
|
||||
)
|
||||
let clockTicker: ReturnType<typeof setInterval> | undefined
|
||||
let unlockTimer: number | undefined
|
||||
let passcodeLockTimer: number | undefined
|
||||
|
||||
function getViewportScale(): number {
|
||||
const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT
|
||||
@@ -182,12 +194,18 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
games.hydrate(payload.device?.data.games?.payload)
|
||||
media.hydrate(payload.device?.data.media?.payload)
|
||||
appStore.hydrate(payload.device?.data.apps?.payload)
|
||||
void mail.bootstrap(payload.account?.email ?? '')
|
||||
if (payload.account?.email) void marketplace.loadCounts()
|
||||
widgets.hydrate(payload.device?.data.widgets?.payload)
|
||||
}
|
||||
|
||||
function loadUnlockedPhoneData(): void {
|
||||
if (unlockedServicesLoaded.value) return
|
||||
unlockedServicesLoaded.value = true
|
||||
void mail.bootstrap(account.email)
|
||||
if (account.email) void marketplace.loadCounts()
|
||||
else marketplace.setCounts({ active: 0, unread: 0 })
|
||||
void calls.bootstrap()
|
||||
void messages.loadConversations()
|
||||
if (payload.account?.email) void darkchat.bootstrap()
|
||||
if (account.email) void darkchat.bootstrap()
|
||||
}
|
||||
|
||||
async function hydrateDevelopmentPhone(): Promise<void> {
|
||||
@@ -389,8 +407,11 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
) {
|
||||
calls.applyCallState(event.data.data as PhoneCall)
|
||||
controlCenterOpened.value = false
|
||||
isLocked.value = false
|
||||
isUnlocking.value = false
|
||||
if (!phone.security.enabled) {
|
||||
isLocked.value = false
|
||||
isUnlocking.value = false
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
window.setTimeout(() => void router.push('/apps/phone'), 0)
|
||||
} else if (event.data?.type === 'sim:picker' && event.data.data) {
|
||||
simPicker.value = event.data.data as unknown as SimPickerPayload
|
||||
@@ -417,14 +438,78 @@ function updateViewportScale(): void {
|
||||
viewportScale.value = getViewportScale()
|
||||
}
|
||||
|
||||
function unlockPhone(): void {
|
||||
function finishUnlock(): void {
|
||||
if (!isLocked.value) return
|
||||
isUnlocking.value = true
|
||||
isLocked.value = false
|
||||
passcodeVisible.value = false
|
||||
passcodeError.value = ''
|
||||
|
||||
unlockTimer = window.setTimeout(() => {
|
||||
isUnlocking.value = false
|
||||
}, 720)
|
||||
|
||||
if (pendingUnlockRoute.value) {
|
||||
const routePath = pendingUnlockRoute.value
|
||||
pendingUnlockRoute.value = null
|
||||
window.setTimeout(() => void router.push(routePath), 0)
|
||||
}
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
|
||||
function unlockPhone(): void {
|
||||
if (!isLocked.value) return
|
||||
if (phone.security.enabled) {
|
||||
passcodeError.value = ''
|
||||
passcodeVisible.value = true
|
||||
return
|
||||
}
|
||||
finishUnlock()
|
||||
}
|
||||
|
||||
function cancelPasscode(): void {
|
||||
if (passcodeBusy.value) return
|
||||
passcodeVisible.value = false
|
||||
passcodeError.value = ''
|
||||
pendingUnlockRoute.value = null
|
||||
}
|
||||
|
||||
function startPasscodeLock(seconds: number): void {
|
||||
if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer)
|
||||
passcodeRetrySeconds.value = Math.max(1, Math.ceil(seconds))
|
||||
passcodeLockTimer = window.setInterval(() => {
|
||||
passcodeRetrySeconds.value = Math.max(0, passcodeRetrySeconds.value - 1)
|
||||
if (passcodeRetrySeconds.value === 0 && passcodeLockTimer !== undefined) {
|
||||
window.clearInterval(passcodeLockTimer)
|
||||
passcodeLockTimer = undefined
|
||||
passcodeError.value = ''
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function submitUnlockPasscode(passcode: string): Promise<void> {
|
||||
if (passcodeBusy.value || passcodeRetrySeconds.value > 0) return
|
||||
passcodeBusy.value = true
|
||||
const response = await phone.unlockWithPasscode(passcode)
|
||||
passcodeBusy.value = false
|
||||
if (response.success) {
|
||||
finishUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
passcodeResetKey.value += 1
|
||||
if (response.error === 'passcode_locked') {
|
||||
startPasscodeLock(response.data?.retryAfter ?? 30)
|
||||
passcodeError.value = phone.t('LockScreen.passcode.locked', {
|
||||
seconds: String(response.data?.retryAfter ?? 30),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (response.error === 'rate_limited') {
|
||||
passcodeError.value = phone.t('LockScreen.passcode.rateLimited')
|
||||
return
|
||||
}
|
||||
passcodeError.value = phone.t('LockScreen.passcode.incorrect')
|
||||
}
|
||||
|
||||
function toggleControlCenter(): void {
|
||||
@@ -433,6 +518,11 @@ function toggleControlCenter(): void {
|
||||
}
|
||||
|
||||
function unlockCamera(): void {
|
||||
if (phone.security.enabled) {
|
||||
pendingUnlockRoute.value = '/apps/camera'
|
||||
unlockPhone()
|
||||
return
|
||||
}
|
||||
unlockPhone()
|
||||
window.setTimeout(() => void router.push('/apps/camera'), 0)
|
||||
}
|
||||
@@ -521,12 +611,33 @@ watch(
|
||||
controlCenterOpened.value = false
|
||||
isLocked.value = false
|
||||
isUnlocking.value = false
|
||||
passcodeVisible.value = false
|
||||
passcodeBusy.value = false
|
||||
passcodeError.value = ''
|
||||
pendingUnlockRoute.value = null
|
||||
unlockedServicesLoaded.value = false
|
||||
if (passcodeLockTimer !== undefined) {
|
||||
window.clearInterval(passcodeLockTimer)
|
||||
passcodeLockTimer = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
isLocked.value = true
|
||||
unlockedServicesLoaded.value = false
|
||||
controlCenterOpened.value = false
|
||||
weather.start()
|
||||
isUnlocking.value = false
|
||||
passcodeVisible.value = false
|
||||
passcodeBusy.value = false
|
||||
passcodeError.value = ''
|
||||
passcodeResetKey.value += 1
|
||||
passcodeRetrySeconds.value = Math.max(
|
||||
0,
|
||||
(phone.security.lockedUntil ?? 0) - Math.floor(Date.now() / 1000),
|
||||
)
|
||||
if (passcodeRetrySeconds.value > 0) {
|
||||
startPasscodeLock(passcodeRetrySeconds.value)
|
||||
}
|
||||
phone.setLaunchOrigin(null)
|
||||
void router.replace('/')
|
||||
},
|
||||
@@ -543,6 +654,7 @@ onBeforeUnmount(() => {
|
||||
weather.stop()
|
||||
if (clockTicker) clearInterval(clockTicker)
|
||||
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
|
||||
if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer)
|
||||
window.removeEventListener('message', onMessage)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
window.removeEventListener('resize', updateViewportScale)
|
||||
@@ -635,6 +747,26 @@ onBeforeUnmount(() => {
|
||||
@unlock="unlockPhone"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition name="lock-screen">
|
||||
<PhonePasscode
|
||||
v-if="isLocked && passcodeVisible"
|
||||
:busy="passcodeBusy"
|
||||
:disabled="passcodeRetrySeconds > 0"
|
||||
:error="passcodeError"
|
||||
:length="phone.security.length ?? 6"
|
||||
:reset-key="passcodeResetKey"
|
||||
:subtitle="
|
||||
passcodeRetrySeconds > 0
|
||||
? phone.t('LockScreen.passcode.tryAgain', {
|
||||
seconds: String(passcodeRetrySeconds),
|
||||
})
|
||||
: phone.t('LockScreen.passcode.unlockSubtitle')
|
||||
"
|
||||
:title="phone.t('LockScreen.passcode.enter')"
|
||||
@cancel="cancelPasscode"
|
||||
@complete="submitUnlockPasscode"
|
||||
/>
|
||||
</Transition>
|
||||
<PhoneNotifications
|
||||
:notification="notifications.current"
|
||||
@close="notifications.dismissCurrent()"
|
||||
|
||||
+1695
-282
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { kBadge } from 'konsta/vue'
|
||||
import { Minus } from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
|
||||
@@ -27,6 +27,7 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
dragcancel: []
|
||||
dragend: [event: PointerEvent]
|
||||
dragmove: [event: PointerEvent]
|
||||
dragstart: [event: PointerEvent]
|
||||
edit: []
|
||||
remove: []
|
||||
@@ -39,16 +40,21 @@ const darkchat = useDarkChatStore()
|
||||
const router = useRouter()
|
||||
const iconFailed = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const calendarToday = ref(new Date())
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
const dragStyle = computed(() =>
|
||||
isDragging.value
|
||||
? {
|
||||
transform: `translate(${dragOffset.value.x}px, ${dragOffset.value.y}px)`,
|
||||
transform: `translateX(${(phone.currentPage - dragStartPage) * dragPageWidth}px)`,
|
||||
translate: `${dragOffset.value.x}px ${dragOffset.value.y}px`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const suppressClick = ref(false)
|
||||
let holdTimer: number | undefined
|
||||
let calendarTimer: number | undefined
|
||||
let pointerStart = { x: 0, y: 0 }
|
||||
const unreadCount = computed(() => {
|
||||
if (props.app.id === 'mail') return mail.counts.unread
|
||||
@@ -60,6 +66,12 @@ const notificationBadgeColors = {
|
||||
bg: 'bg-[#ff3b30]',
|
||||
text: 'text-white',
|
||||
}
|
||||
const calendarWeekday = computed(() =>
|
||||
new Intl.DateTimeFormat(phone.lang, { weekday: 'short' })
|
||||
.format(calendarToday.value)
|
||||
.replace(/\.$/, ''),
|
||||
)
|
||||
const calendarDay = computed(() => calendarToday.value.getDate())
|
||||
|
||||
function launch(event: MouseEvent): void {
|
||||
if (props.editMode || suppressClick.value) {
|
||||
@@ -124,6 +136,7 @@ function onPointerMove(event: PointerEvent): void {
|
||||
x: event.clientX - pointerStart.x,
|
||||
y: event.clientY - pointerStart.y,
|
||||
}
|
||||
emit('dragmove', event)
|
||||
return
|
||||
}
|
||||
if (
|
||||
@@ -135,6 +148,11 @@ function onPointerMove(event: PointerEvent): void {
|
||||
}
|
||||
|
||||
function beginPointerDrag(event: PointerEvent): void {
|
||||
dragStartPage = phone.currentPage
|
||||
dragPageWidth =
|
||||
(event.target as HTMLElement)
|
||||
.closest<HTMLElement>('.springboard-page')
|
||||
?.getBoundingClientRect().width ?? 0
|
||||
isDragging.value = true
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
@@ -167,8 +185,16 @@ function removeDragListeners(): void {
|
||||
window.removeEventListener('pointercancel', cancelPointerDrag)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.app.id !== 'calendar') return
|
||||
calendarTimer = window.setInterval(() => {
|
||||
calendarToday.value = new Date()
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearHold()
|
||||
if (calendarTimer !== undefined) window.clearInterval(calendarTimer)
|
||||
removeDragListeners()
|
||||
})
|
||||
</script>
|
||||
@@ -200,10 +226,17 @@ onBeforeUnmount(() => {
|
||||
<span class="app-icon-anchor" aria-hidden="true">
|
||||
<span
|
||||
class="app-icon"
|
||||
:class="[app.iconClass, { 'app-icon--image': !iconFailed }]"
|
||||
:class="[
|
||||
app.iconClass,
|
||||
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
|
||||
]"
|
||||
>
|
||||
<span v-if="app.id === 'calendar'" class="app-icon-calendar">
|
||||
<strong>{{ calendarWeekday }}</strong>
|
||||
<b>{{ calendarDay }}</b>
|
||||
</span>
|
||||
<img
|
||||
v-if="!iconFailed"
|
||||
v-else-if="!iconFailed"
|
||||
:src="app.iconImage"
|
||||
alt=""
|
||||
draggable="false"
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import { Delete } from 'lucide-vue-next'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
busy?: boolean
|
||||
cancelable?: boolean
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
length: 4 | 6
|
||||
resetKey?: number
|
||||
subtitle?: string
|
||||
title: string
|
||||
}>(),
|
||||
{
|
||||
busy: false,
|
||||
cancelable: true,
|
||||
disabled: false,
|
||||
error: '',
|
||||
resetKey: 0,
|
||||
subtitle: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
complete: [passcode: string]
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const digits = ref('')
|
||||
const keypad = [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
const inputDisabled = computed(() => props.busy || props.disabled)
|
||||
|
||||
function enterDigit(digit: number): void {
|
||||
if (inputDisabled.value || digits.value.length >= props.length) return
|
||||
digits.value += String(digit)
|
||||
if (digits.value.length === props.length) emit('complete', digits.value)
|
||||
}
|
||||
|
||||
function removeDigit(): void {
|
||||
if (inputDisabled.value) return
|
||||
digits.value = digits.value.slice(0, -1)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.resetKey,
|
||||
() => {
|
||||
digits.value = ''
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="passcode-screen" :aria-label="title">
|
||||
<header class="passcode-screen__header">
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="subtitle">{{ subtitle }}</p>
|
||||
<div class="passcode-screen__dots" aria-hidden="true">
|
||||
<span
|
||||
v-for="index in length"
|
||||
:key="index"
|
||||
:class="{ 'passcode-screen__dot--filled': digits.length >= index }"
|
||||
></span>
|
||||
</div>
|
||||
<p
|
||||
v-if="error"
|
||||
class="passcode-screen__error"
|
||||
role="alert"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="passcode-screen__keypad">
|
||||
<button
|
||||
v-for="digit in keypad"
|
||||
:key="digit"
|
||||
type="button"
|
||||
:disabled="inputDisabled"
|
||||
@click="enterDigit(digit)"
|
||||
>
|
||||
{{ digit }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="passcode-screen__action"
|
||||
:disabled="!cancelable || busy"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ cancelable ? phone.t('LockScreen.passcode.cancel') : '' }}
|
||||
</button>
|
||||
<button type="button" :disabled="inputDisabled" @click="enterDigit(0)">
|
||||
0
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="passcode-screen__action"
|
||||
:aria-label="phone.t('LockScreen.passcode.delete')"
|
||||
:disabled="inputDisabled || digits.length === 0"
|
||||
@click="removeDigit"
|
||||
>
|
||||
<Delete :size="25" :stroke-width="1.7" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.passcode-screen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 76px 28px 30px;
|
||||
color: white;
|
||||
background:
|
||||
radial-gradient(circle at 50% 16%, rgb(76 92 132 / 46%), transparent 35%),
|
||||
linear-gradient(160deg, #182139, #080b12 72%);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.passcode-screen__header {
|
||||
min-height: 170px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.passcode-screen__header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.passcode-screen__header p {
|
||||
max-width: 270px;
|
||||
margin: 8px auto 0;
|
||||
color: rgb(255 255 255 / 72%);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.passcode-screen__dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.passcode-screen__dots span {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid rgb(255 255 255 / 78%);
|
||||
border-radius: 50%;
|
||||
transition: background-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.passcode-screen__dots .passcode-screen__dot--filled {
|
||||
background: white;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.passcode-screen__header .passcode-screen__error {
|
||||
color: #ff9b93;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 72px);
|
||||
gap: 15px 20px;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad button:not(.passcode-screen__action) {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
background: rgb(255 255 255 / 16%);
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
backdrop-filter: blur(18px);
|
||||
transition: background-color 100ms ease, transform 100ms ease;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad button:not(.passcode-screen__action):active {
|
||||
background: rgb(255 255 255 / 34%);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.passcode-screen__keypad button:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.passcode-screen__keypad .passcode-screen__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 72px;
|
||||
min-height: 48px;
|
||||
border: 0;
|
||||
color: white;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,831 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Banknote,
|
||||
Cloud,
|
||||
CloudFog,
|
||||
CloudLightning,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
MessageCircle,
|
||||
MoonStar,
|
||||
Pause,
|
||||
Phone,
|
||||
Play,
|
||||
SkipForward,
|
||||
Snowflake,
|
||||
Sun,
|
||||
WalletCards,
|
||||
} from 'lucide-vue-next'
|
||||
import { kBadge, kGlass } from 'konsta/vue'
|
||||
import { computed, onBeforeUnmount, ref, type Component } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
useBankService,
|
||||
useClockService,
|
||||
useContactsService,
|
||||
useMusicService,
|
||||
useWeatherService,
|
||||
} from '@/services/widgetServices'
|
||||
import { useCallsStore } from '@/stores/calls'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { WidgetInstance } from '@/types/widgets'
|
||||
import type { WeatherConditionId } from '@/types/weather'
|
||||
import { WIDGET_SPANS } from '@/utils/widgetLayout'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
editMode?: boolean
|
||||
instance: WidgetInstance
|
||||
interactive?: boolean
|
||||
preview?: boolean
|
||||
}>(),
|
||||
{ editMode: false, interactive: true, preview: false },
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
dragcancel: []
|
||||
dragend: [event: PointerEvent]
|
||||
dragmove: [event: PointerEvent]
|
||||
dragstart: [event: PointerEvent]
|
||||
menu: []
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const calls = useCallsStore()
|
||||
const messages = useMessagesStore()
|
||||
const router = useRouter()
|
||||
const clock = useClockService()
|
||||
const weather = useWeatherService()
|
||||
const music = useMusicService()
|
||||
const bank = useBankService()
|
||||
const contactsService = useContactsService()
|
||||
const isDragging = ref(false)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
const suppressClick = ref(false)
|
||||
let holdTimer: number | undefined
|
||||
let pointerStart = { x: 0, y: 0 }
|
||||
let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
|
||||
const weatherIcons: Record<WeatherConditionId, Component> = {
|
||||
sunny: Sun,
|
||||
clear: MoonStar,
|
||||
partly_cloudy: CloudSun,
|
||||
cloudy: Cloud,
|
||||
rain: CloudRain,
|
||||
thunder: CloudLightning,
|
||||
fog: CloudFog,
|
||||
snow: Snowflake,
|
||||
}
|
||||
const span = computed(() => WIDGET_SPANS[props.instance.size])
|
||||
const placementStyle = computed(() =>
|
||||
props.preview
|
||||
? undefined
|
||||
: {
|
||||
gridColumn: `${props.instance.column + 1} / span ${span.value.columns}`,
|
||||
gridRow: `${props.instance.row + 1} / span ${span.value.rows}`,
|
||||
translate: isDragging.value
|
||||
? `${dragOffset.value.x}px ${dragOffset.value.y}px`
|
||||
: undefined,
|
||||
transform: isDragging.value
|
||||
? `translateX(${(phone.currentPage - dragStartPage) * dragPageWidth}px) scale(1.035)`
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
const forecastHigh = computed(() =>
|
||||
weather.forecast.value?.hourly.length
|
||||
? Math.max(
|
||||
...weather.forecast.value.hourly.map((entry) => entry.temperature),
|
||||
)
|
||||
: null,
|
||||
)
|
||||
const forecastLow = computed(() =>
|
||||
weather.forecast.value?.hourly.length
|
||||
? Math.min(
|
||||
...weather.forecast.value.hourly.map((entry) => entry.temperature),
|
||||
)
|
||||
: null,
|
||||
)
|
||||
const weatherIcon = computed(
|
||||
() => weatherIcons[weather.forecast.value?.condition ?? 'partly_cloudy'],
|
||||
)
|
||||
const balance = computed(() =>
|
||||
props.instance.settings.balanceSource === 'cash'
|
||||
? bank.overview.value.cash
|
||||
: bank.overview.value.bank,
|
||||
)
|
||||
const favoriteContacts = computed(() => {
|
||||
const selected = props.instance.settings.contactIds ?? []
|
||||
const ordered = selected.length
|
||||
? selected
|
||||
.map((id) =>
|
||||
contactsService.contacts.value.find((contact) => contact.id === id),
|
||||
)
|
||||
.filter((contact) => contact !== undefined)
|
||||
: contactsService.contacts.value
|
||||
return ordered.slice(0, props.instance.size === 'large' ? 6 : 4)
|
||||
})
|
||||
const visibleTransactions = computed(() =>
|
||||
bank.overview.value.transactions.slice(
|
||||
0,
|
||||
props.instance.size === 'large' ? 5 : 2,
|
||||
),
|
||||
)
|
||||
const removeBadgeColors = {
|
||||
bg: 'bg-[#d1d1d6]',
|
||||
text: 'text-black',
|
||||
}
|
||||
|
||||
function formatMoney(value: number): string {
|
||||
return `${bank.overview.value.currency}${new Intl.NumberFormat(phone.lang, {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)}`
|
||||
}
|
||||
|
||||
function avatar(name: string): string {
|
||||
return name.trim().charAt(0).toLocaleUpperCase(phone.lang) || '?'
|
||||
}
|
||||
|
||||
function clearHold(): void {
|
||||
if (holdTimer !== undefined) window.clearTimeout(holdTimer)
|
||||
holdTimer = undefined
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
if (
|
||||
!props.interactive ||
|
||||
props.preview ||
|
||||
event.button !== 0 ||
|
||||
(event.target as HTMLElement).closest('[data-widget-control]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
pointerStart = { x: event.clientX, y: event.clientY }
|
||||
clearHold()
|
||||
if (props.editMode) {
|
||||
beginDrag(event)
|
||||
return
|
||||
}
|
||||
holdTimer = window.setTimeout(() => {
|
||||
suppressClick.value = true
|
||||
emit('menu')
|
||||
holdTimer = undefined
|
||||
}, 520)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (isDragging.value) {
|
||||
dragOffset.value = {
|
||||
x: event.clientX - pointerStart.x,
|
||||
y: event.clientY - pointerStart.y,
|
||||
}
|
||||
emit('dragmove', event)
|
||||
return
|
||||
}
|
||||
if (
|
||||
Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >
|
||||
8
|
||||
) {
|
||||
clearHold()
|
||||
}
|
||||
}
|
||||
|
||||
function beginDrag(event: PointerEvent): void {
|
||||
dragStartPage = phone.currentPage
|
||||
dragPageWidth =
|
||||
(event.currentTarget as HTMLElement)
|
||||
.closest<HTMLElement>('.springboard-page')
|
||||
?.getBoundingClientRect().width ?? 0
|
||||
isDragging.value = true
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', cancelDrag)
|
||||
emit('dragstart', event)
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent): void {
|
||||
clearHold()
|
||||
if (!isDragging.value) return
|
||||
suppressClick.value = true
|
||||
emit('dragend', event)
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
removeDragListeners()
|
||||
}
|
||||
|
||||
function cancelDrag(): void {
|
||||
clearHold()
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
dragOffset.value = { x: 0, y: 0 }
|
||||
removeDragListeners()
|
||||
emit('dragcancel')
|
||||
}
|
||||
|
||||
function removeDragListeners(): void {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', cancelDrag)
|
||||
}
|
||||
|
||||
function openWidget(): void {
|
||||
if (props.editMode || suppressClick.value || !props.interactive) {
|
||||
suppressClick.value = false
|
||||
return
|
||||
}
|
||||
const routes: Partial<Record<WidgetInstance['kind'], string>> = {
|
||||
clock: '/apps/clock',
|
||||
contacts: '/apps/phone',
|
||||
date: '/apps/calendar',
|
||||
transactions: '/apps/banking',
|
||||
wallet: '/apps/banking',
|
||||
weather: '/apps/weather',
|
||||
}
|
||||
const route = routes[props.instance.kind]
|
||||
if (route) {
|
||||
phone.setLaunchOrigin(null)
|
||||
void router.push(route)
|
||||
}
|
||||
}
|
||||
|
||||
async function callContact(phoneNumber: string): Promise<void> {
|
||||
await calls.dial(phoneNumber)
|
||||
phone.setLaunchOrigin(null)
|
||||
void router.push('/apps/phone')
|
||||
}
|
||||
|
||||
async function messageContact(phoneNumber: string): Promise<void> {
|
||||
await messages.openThread(phoneNumber)
|
||||
phone.setLaunchOrigin(null)
|
||||
void router.push('/apps/messages')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearHold()
|
||||
removeDragListeners()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="home-widget-shell"
|
||||
:class="[
|
||||
`home-widget-shell--${instance.size}`,
|
||||
{
|
||||
'home-widget-shell--dragging': isDragging,
|
||||
'home-widget-shell--editing': editMode,
|
||||
'home-widget-shell--preview': preview,
|
||||
},
|
||||
]"
|
||||
:style="placementStyle"
|
||||
:data-widget-id="instance.id"
|
||||
>
|
||||
<k-glass
|
||||
component="article"
|
||||
class="home-widget"
|
||||
:class="`home-widget--${instance.kind}`"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openWidget"
|
||||
@contextmenu.prevent
|
||||
@keydown.enter="openWidget"
|
||||
@pointercancel="cancelDrag"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointerleave="isDragging || clearHold()"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
>
|
||||
<template v-if="instance.kind === 'clock'">
|
||||
<span class="widget-eyebrow">{{ clock.weekday.value }}</span>
|
||||
<strong class="widget-clock">{{ clock.time.value }}</strong>
|
||||
<small v-if="instance.settings.showDate !== false">{{
|
||||
clock.date.value
|
||||
}}</small>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.kind === 'date'">
|
||||
<span class="widget-date-month">{{ clock.month.value }}</span>
|
||||
<strong class="widget-date-day">{{ clock.day.value }}</strong>
|
||||
<small>{{ clock.weekday.value }}</small>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.kind === 'weather'">
|
||||
<div class="widget-weather-top">
|
||||
<div>
|
||||
<span class="widget-eyebrow">{{ weather.location.value }}</span>
|
||||
<strong>{{ weather.forecast.value?.temperature ?? '--' }}°</strong>
|
||||
</div>
|
||||
<component
|
||||
:is="weatherIcon"
|
||||
:size="instance.size === 'small' ? 30 : 40"
|
||||
/>
|
||||
</div>
|
||||
<small>{{ weather.condition.value }}</small>
|
||||
<small v-if="instance.size !== 'small'" class="widget-weather-range">
|
||||
H: {{ forecastHigh ?? '--' }}° L: {{ forecastLow ?? '--' }}°
|
||||
</small>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.kind === 'music'">
|
||||
<div class="widget-album" aria-hidden="true">
|
||||
<span>SKY</span>
|
||||
</div>
|
||||
<div class="widget-music-copy">
|
||||
<strong>{{ music.current.value.title }}</strong>
|
||||
<small>{{ music.current.value.artist }}</small>
|
||||
</div>
|
||||
<div class="widget-music-controls" data-widget-control>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
music.playing.value
|
||||
? 'Home.widgets.media.pause'
|
||||
: 'Home.widgets.media.play',
|
||||
)
|
||||
"
|
||||
@click.stop="music.toggle"
|
||||
>
|
||||
<Pause v-if="music.playing.value" :size="20" fill="currentColor" />
|
||||
<Play v-else :size="20" fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('ControlCenter.next')"
|
||||
@click.stop="music.next"
|
||||
>
|
||||
<SkipForward :size="19" fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.kind === 'wallet'">
|
||||
<div class="widget-wallet-icon"><WalletCards :size="22" /></div>
|
||||
<span class="widget-eyebrow">{{
|
||||
phone.t(
|
||||
instance.settings.balanceSource === 'cash'
|
||||
? 'Home.widgetSystem.wallet.cash'
|
||||
: 'Home.widgetSystem.wallet.bank',
|
||||
)
|
||||
}}</span>
|
||||
<strong class="widget-balance">{{ formatMoney(balance) }}</strong>
|
||||
<small>{{ bank.overview.value.playerName }}</small>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.kind === 'transactions'">
|
||||
<header class="widget-list-header">
|
||||
<span>{{ phone.t('Home.widgetSystem.transactions.name') }}</span>
|
||||
<Banknote :size="19" />
|
||||
</header>
|
||||
<button
|
||||
v-for="transaction in visibleTransactions"
|
||||
:key="transaction.id"
|
||||
type="button"
|
||||
class="widget-transaction"
|
||||
data-widget-control
|
||||
@click.stop="openWidget"
|
||||
>
|
||||
<span>
|
||||
<strong>{{ transaction.label }}</strong>
|
||||
<small>{{ transaction.reference }}</small>
|
||||
</span>
|
||||
<b
|
||||
:class="{
|
||||
positive:
|
||||
transaction.kind === 'deposit' ||
|
||||
transaction.kind === 'transfer_in',
|
||||
}"
|
||||
>{{
|
||||
transaction.kind === 'deposit' ||
|
||||
transaction.kind === 'transfer_in'
|
||||
? '+'
|
||||
: '−'
|
||||
}}{{ formatMoney(transaction.amount) }}</b
|
||||
>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.kind === 'contacts'">
|
||||
<header class="widget-list-header">
|
||||
<span>{{ phone.t('Home.widgetSystem.contacts.name') }}</span>
|
||||
</header>
|
||||
<div class="widget-contacts">
|
||||
<article v-for="contact in favoriteContacts" :key="contact.id">
|
||||
<span class="widget-contact-avatar">{{
|
||||
avatar(contact.name)
|
||||
}}</span>
|
||||
<strong>{{ contact.name }}</strong>
|
||||
<div data-widget-control>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.messages.call')"
|
||||
@click.stop="callContact(contact.phone_number)"
|
||||
>
|
||||
<Phone :size="15" fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.messages.messageAction')"
|
||||
@click.stop="messageContact(contact.phone_number)"
|
||||
>
|
||||
<MessageCircle :size="15" fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</k-glass>
|
||||
|
||||
<button
|
||||
v-if="editMode && !preview"
|
||||
class="home-widget-remove"
|
||||
type="button"
|
||||
:aria-label="phone.t('Home.widgetSystem.remove')"
|
||||
@click.stop="emit('remove')"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<k-badge :colors="removeBadgeColors">−</k-badge>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-widget-shell {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
transition:
|
||||
transform 0.32s cubic-bezier(0.32, 0.72, 0, 1),
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.home-widget-shell--dragging {
|
||||
z-index: 40;
|
||||
opacity: 0.86;
|
||||
transition:
|
||||
transform var(--springboard-page-duration) var(--springboard-page-easing),
|
||||
opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-widget-shell--editing:not(.home-widget-shell--dragging) {
|
||||
animation: widget-wobble 0.17s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.home-widget-shell--preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 1;
|
||||
}
|
||||
|
||||
.home-widget-shell--preview.home-widget-shell--small {
|
||||
max-width: 150px;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.home-widget-shell--preview.home-widget-shell--large {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.home-widget {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
border: 0.5px solid rgb(255 255 255 / 18%);
|
||||
border-radius: 22px;
|
||||
outline: none;
|
||||
color: #fff;
|
||||
background: rgb(28 28 30 / 76%);
|
||||
box-shadow:
|
||||
0 8px 24px rgb(0 0 0 / 24%),
|
||||
inset 0 0.5px rgb(255 255 255 / 18%);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.home-widget:active {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.home-widget small,
|
||||
.widget-eyebrow {
|
||||
color: rgb(255 255 255 / 68%);
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.home-widget--clock,
|
||||
.home-widget--date,
|
||||
.home-widget--wallet {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.widget-clock {
|
||||
margin: 1px 0;
|
||||
font-size: 31px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -1.6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.home-widget-shell--medium .widget-clock {
|
||||
font-size: 45px;
|
||||
}
|
||||
|
||||
.home-widget--date {
|
||||
background: rgb(247 247 247 / 88%);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.home-widget--date small {
|
||||
color: #6e6e73;
|
||||
}
|
||||
|
||||
.widget-date-month {
|
||||
color: #ff3b30;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.widget-date-day {
|
||||
font-size: 48px;
|
||||
font-weight: 300;
|
||||
letter-spacing: -2px;
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.home-widget--weather {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
background: rgb(25 69 112 / 82%);
|
||||
}
|
||||
|
||||
.widget-weather-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.widget-weather-top > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.widget-weather-top strong {
|
||||
font-size: 34px;
|
||||
font-weight: 300;
|
||||
letter-spacing: -1.5px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.widget-weather-range {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.home-widget--music {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.home-widget-shell--large .home-widget--music {
|
||||
align-content: center;
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.widget-album {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
border-radius: 13px;
|
||||
color: #fff;
|
||||
background: #b33a3a;
|
||||
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 22%);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
|
||||
.home-widget-shell--large .widget-album {
|
||||
width: 118px;
|
||||
height: 118px;
|
||||
margin: 0 auto;
|
||||
border-radius: 24px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.widget-music-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.widget-music-copy strong,
|
||||
.widget-music-copy small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.widget-music-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.widget-music-controls button,
|
||||
.widget-contacts button {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: rgb(255 255 255 / 13%);
|
||||
}
|
||||
|
||||
.widget-wallet-icon {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
right: 13px;
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: #0a84ff;
|
||||
background: rgb(10 132 255 / 15%);
|
||||
}
|
||||
|
||||
.widget-balance {
|
||||
margin: 2px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -1.2px;
|
||||
}
|
||||
|
||||
.home-widget--transactions,
|
||||
.home-widget--contacts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.widget-list-header {
|
||||
display: flex;
|
||||
margin-bottom: 7px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: rgb(255 255 255 / 72%);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-transaction {
|
||||
display: flex;
|
||||
min-height: 43px;
|
||||
padding: 5px 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
border-top: 0.5px solid rgb(255 255 255 / 12%);
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.widget-transaction > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.widget-transaction strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.widget-transaction b {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-transaction b.positive {
|
||||
color: #30d158;
|
||||
}
|
||||
|
||||
.widget-contacts {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.home-widget-shell--large .widget-contacts {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.widget-contacts article {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.widget-contact-avatar {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: #5e5ce6;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.widget-contacts article:nth-child(2n) .widget-contact-avatar {
|
||||
background: #ff9f0a;
|
||||
}
|
||||
|
||||
.widget-contacts article:nth-child(3n) .widget-contact-avatar {
|
||||
background: #34c759;
|
||||
}
|
||||
|
||||
.widget-contacts strong {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.widget-contacts article > div {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.widget-contacts button {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
color: #0a84ff;
|
||||
background: rgb(10 132 255 / 14%);
|
||||
}
|
||||
|
||||
.home-widget-remove {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
display: grid;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.home-widget-remove :deep(.k-badge) {
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
min-width: 23px;
|
||||
padding: 0;
|
||||
border: 0.5px solid rgb(255 255 255 / 55%);
|
||||
box-shadow: 0 1px 5px rgb(0 0 0 / 55%);
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@keyframes widget-wobble {
|
||||
from {
|
||||
transform: rotate(-0.7deg) translateY(-0.5px);
|
||||
}
|
||||
to {
|
||||
transform: rotate(0.7deg) translateY(0.5px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-widget-shell--editing {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SpringboardWidget from '@/components/SpringboardWidget.vue'
|
||||
import { useWidgetsStore } from '@/stores/widgets'
|
||||
import { WIDGET_HOME_ROWS, WIDGET_PAGE_ROWS } from '@/utils/widgetLayout'
|
||||
|
||||
const props = defineProps<{
|
||||
draggingWidgetId?: string | null
|
||||
editMode: boolean
|
||||
page: number
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
dragcancel: []
|
||||
dragend: [event: PointerEvent]
|
||||
dragmove: [event: PointerEvent]
|
||||
dragstart: [id: string, event: PointerEvent]
|
||||
menu: [id: string]
|
||||
remove: [id: string]
|
||||
}>()
|
||||
const widgets = useWidgetsStore()
|
||||
const rows = computed(() =>
|
||||
props.page === 0 ? WIDGET_PAGE_ROWS : WIDGET_HOME_ROWS,
|
||||
)
|
||||
const instances = computed(() =>
|
||||
widgets.layout.instances.filter((instance) => instance.page === props.page),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="springboard-widget-grid"
|
||||
:class="{
|
||||
'springboard-widget-grid--editing': editMode,
|
||||
'springboard-widget-grid--page': page === 0,
|
||||
'springboard-widget-grid--drag-source': instances.some(
|
||||
(instance) => instance.id === draggingWidgetId,
|
||||
),
|
||||
}"
|
||||
:style="{ '--widget-grid-rows': rows }"
|
||||
:data-widget-page="page"
|
||||
>
|
||||
<SpringboardWidget
|
||||
v-for="instance in instances"
|
||||
:key="instance.id"
|
||||
:instance="instance"
|
||||
:edit-mode="editMode"
|
||||
@dragcancel="emit('dragcancel')"
|
||||
@dragend="emit('dragend', $event)"
|
||||
@dragmove="emit('dragmove', $event)"
|
||||
@dragstart="emit('dragstart', instance.id, $event)"
|
||||
@menu="emit('menu', instance.id)"
|
||||
@remove="emit('remove', instance.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.springboard-widget-grid {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 82px;
|
||||
right: 22px;
|
||||
left: 22px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(
|
||||
var(--widget-grid-rows),
|
||||
var(--springboard-grid-row)
|
||||
);
|
||||
grid-auto-rows: var(--springboard-grid-row);
|
||||
gap: var(--springboard-grid-row-gap) var(--springboard-grid-column-gap);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.springboard-widget-grid--page {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.springboard-widget-grid--drag-source {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.springboard-widget-grid :deep(.home-widget-shell) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import {
|
||||
kLink,
|
||||
kList,
|
||||
kListItem,
|
||||
kNavbar,
|
||||
kPage,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
kSheet,
|
||||
kToggle,
|
||||
} from 'konsta/vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import SpringboardWidget from '@/components/SpringboardWidget.vue'
|
||||
import { WIDGET_REGISTRY_BY_KIND } from '@/config/widgets'
|
||||
import { useContactsService } from '@/services/widgetServices'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
WidgetInstance,
|
||||
WidgetSettings,
|
||||
WidgetSize,
|
||||
} from '@/types/widgets'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: WidgetInstance | null
|
||||
opened: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
save: [size: WidgetSize, settings: WidgetSettings]
|
||||
}>()
|
||||
const phone = usePhoneStore()
|
||||
const contactsService = useContactsService()
|
||||
const size = ref<WidgetSize>('small')
|
||||
const showDate = ref(true)
|
||||
const balanceSource = ref<'bank' | 'cash'>('bank')
|
||||
const contactIds = ref<string[]>([])
|
||||
const sheetColors = {
|
||||
bgIos: 'bg-[#f2f2f7] dark:bg-black',
|
||||
}
|
||||
|
||||
function toggleContact(id: string): void {
|
||||
const index = contactIds.value.indexOf(id)
|
||||
if (index !== -1) contactIds.value.splice(index, 1)
|
||||
else if (contactIds.value.length < 6) contactIds.value.push(id)
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
emit('save', size.value, {
|
||||
balanceSource: balanceSource.value,
|
||||
contactIds: contactIds.value,
|
||||
showDate: showDate.value,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.opened, () => props.instance],
|
||||
([opened, instance]) => {
|
||||
if (!opened || !instance) return
|
||||
size.value = instance.size
|
||||
showDate.value = instance.settings.showDate !== false
|
||||
balanceSource.value = instance.settings.balanceSource ?? 'bank'
|
||||
contactIds.value = [...(instance.settings.contactIds ?? [])]
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-sheet
|
||||
:opened="opened"
|
||||
class="widget-config-sheet"
|
||||
:colors="sheetColors"
|
||||
@backdropclick="emit('close')"
|
||||
>
|
||||
<k-page
|
||||
v-if="instance"
|
||||
class="widget-config-page"
|
||||
:class="{ 'widget-config-page--dark': phone.isDarkMode }"
|
||||
>
|
||||
<k-navbar :title="phone.t('Home.widgetSystem.configure')">
|
||||
<template #left>
|
||||
<k-link component="button" type="button" @click="emit('close')">
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</k-link>
|
||||
</template>
|
||||
<template #right>
|
||||
<k-link component="button" type="button" @click="save">
|
||||
{{ phone.t('Common.done') }}
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
|
||||
<div class="widget-config-scroll">
|
||||
<div class="widget-config-preview">
|
||||
<SpringboardWidget
|
||||
:instance="{ ...instance, size }"
|
||||
preview
|
||||
:interactive="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section class="widget-config-size">
|
||||
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
|
||||
<k-segmented raised>
|
||||
<k-segmented-button
|
||||
v-for="supportedSize in WIDGET_REGISTRY_BY_KIND.get(instance.kind)
|
||||
?.supportedSizes"
|
||||
:key="supportedSize"
|
||||
:active="size === supportedSize"
|
||||
@click="size = supportedSize"
|
||||
>
|
||||
{{ phone.t(`Home.widgetSystem.sizes.${supportedSize}`) }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</section>
|
||||
|
||||
<k-list
|
||||
v-if="instance.kind === 'clock'"
|
||||
inset
|
||||
strong
|
||||
class="widget-config-list"
|
||||
>
|
||||
<k-list-item :title="phone.t('Home.widgetSystem.clock.showDate')">
|
||||
<template #after>
|
||||
<k-toggle :checked="showDate" @change="showDate = !showDate" />
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
|
||||
<k-list
|
||||
v-if="instance.kind === 'wallet'"
|
||||
inset
|
||||
strong
|
||||
class="widget-config-list"
|
||||
>
|
||||
<k-list-item :title="phone.t('Home.widgetSystem.wallet.balance')">
|
||||
<template #after>
|
||||
<k-segmented class="widget-config-balance">
|
||||
<k-segmented-button
|
||||
:active="balanceSource === 'bank'"
|
||||
@click="balanceSource = 'bank'"
|
||||
>
|
||||
{{ phone.t('Home.widgetSystem.wallet.bank') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
:active="balanceSource === 'cash'"
|
||||
@click="balanceSource = 'cash'"
|
||||
>
|
||||
{{ phone.t('Home.widgetSystem.wallet.cash') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
|
||||
<section v-if="instance.kind === 'contacts'">
|
||||
<h3>{{ phone.t('Home.widgetSystem.contacts.choose') }}</h3>
|
||||
<k-list inset strong class="widget-config-list">
|
||||
<k-list-item
|
||||
v-for="contact in contactsService.contacts.value"
|
||||
:key="contact.id"
|
||||
link
|
||||
link-component="button"
|
||||
:title="contact.name"
|
||||
:subtitle="contact.phone_number"
|
||||
@click="toggleContact(contact.id)"
|
||||
>
|
||||
<template #media>
|
||||
<span class="widget-config-avatar">{{
|
||||
contact.name.charAt(0).toUpperCase()
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-if="contactIds.includes(contact.id)" #after>
|
||||
<Check :size="20" class="widget-config-check" />
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
</section>
|
||||
</div>
|
||||
</k-page>
|
||||
</k-sheet>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.widget-config-sheet) {
|
||||
z-index: 115;
|
||||
height: calc(100% - 22px);
|
||||
overflow: hidden;
|
||||
border-radius: 28px 28px 0 0;
|
||||
}
|
||||
|
||||
.widget-config-page {
|
||||
height: 100%;
|
||||
color: #111;
|
||||
background: #f2f2f7;
|
||||
}
|
||||
|
||||
.widget-config-page--dark {
|
||||
color: #fff;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.widget-config-scroll {
|
||||
height: calc(100% - 54px);
|
||||
padding: 8px 12px 42px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.widget-config-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.widget-config-preview {
|
||||
display: flex;
|
||||
min-height: 215px;
|
||||
padding: 20px 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.widget-config-preview :deep(.home-widget-shell) {
|
||||
max-height: 188px;
|
||||
}
|
||||
|
||||
.widget-config-size {
|
||||
display: grid;
|
||||
margin: 0 4px 16px;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.widget-config-size > span,
|
||||
.widget-config-scroll h3 {
|
||||
color: #6e6e73;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-config-scroll h3 {
|
||||
margin: 18px 14px 7px;
|
||||
}
|
||||
|
||||
.widget-config-list :deep([class*='title']) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.widget-config-balance {
|
||||
width: 138px;
|
||||
}
|
||||
|
||||
.widget-config-avatar {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: #5e5ce6;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.widget-config-check {
|
||||
color: #0a84ff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CalendarDays,
|
||||
Check,
|
||||
Clock3,
|
||||
CloudSun,
|
||||
Music,
|
||||
ReceiptText,
|
||||
Users,
|
||||
WalletCards,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
kButton,
|
||||
kLink,
|
||||
kList,
|
||||
kListItem,
|
||||
kNavbar,
|
||||
kPage,
|
||||
kSearchbar,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
kSheet,
|
||||
} from 'konsta/vue'
|
||||
import { computed, ref, watch, type Component } from 'vue'
|
||||
|
||||
import SpringboardWidget from '@/components/SpringboardWidget.vue'
|
||||
import { WIDGET_REGISTRY } from '@/config/widgets'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
WidgetDefinition,
|
||||
WidgetInstance,
|
||||
WidgetKind,
|
||||
WidgetSize,
|
||||
} from '@/types/widgets'
|
||||
|
||||
const props = defineProps<{ opened: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
add: [kind: WidgetKind, size: WidgetSize]
|
||||
close: []
|
||||
}>()
|
||||
const phone = usePhoneStore()
|
||||
const query = ref('')
|
||||
const selectedKind = ref<WidgetKind>('clock')
|
||||
const selectedSize = ref<WidgetSize>('small')
|
||||
|
||||
const icons: Record<WidgetKind, Component> = {
|
||||
clock: Clock3,
|
||||
date: CalendarDays,
|
||||
weather: CloudSun,
|
||||
music: Music,
|
||||
wallet: WalletCards,
|
||||
transactions: ReceiptText,
|
||||
contacts: Users,
|
||||
}
|
||||
const filteredWidgets = computed(() => {
|
||||
const search = query.value.trim().toLocaleLowerCase(phone.lang)
|
||||
if (!search) return WIDGET_REGISTRY
|
||||
return WIDGET_REGISTRY.filter((definition) =>
|
||||
`${phone.t(definition.labelKey)} ${phone.t(definition.descriptionKey)}`
|
||||
.toLocaleLowerCase(phone.lang)
|
||||
.includes(search),
|
||||
)
|
||||
})
|
||||
const categories = computed(() => {
|
||||
const groups = new Map<string, WidgetDefinition[]>()
|
||||
for (const definition of filteredWidgets.value) {
|
||||
const group = groups.get(definition.categoryKey) ?? []
|
||||
group.push(definition)
|
||||
groups.set(definition.categoryKey, group)
|
||||
}
|
||||
return [...groups.entries()].map(([key, widgets]) => ({ key, widgets }))
|
||||
})
|
||||
const selectedDefinition = computed(
|
||||
() =>
|
||||
WIDGET_REGISTRY.find(
|
||||
(definition) => definition.kind === selectedKind.value,
|
||||
) ?? WIDGET_REGISTRY[0],
|
||||
)
|
||||
const previewInstance = computed<WidgetInstance>(() => ({
|
||||
column: 0,
|
||||
id: 'widget-preview',
|
||||
kind: selectedDefinition.value.kind,
|
||||
page: 0,
|
||||
row: 0,
|
||||
settings: {
|
||||
...(selectedDefinition.value.kind === 'clock' ? { showDate: true } : {}),
|
||||
...(selectedDefinition.value.kind === 'wallet'
|
||||
? { balanceSource: 'bank' as const }
|
||||
: {}),
|
||||
},
|
||||
size: selectedSize.value,
|
||||
}))
|
||||
const sheetColors = {
|
||||
bgIos: 'bg-[#f2f2f7] dark:bg-black',
|
||||
}
|
||||
|
||||
function inputValue(event: Event): string {
|
||||
return (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
function selectWidget(definition: WidgetDefinition): void {
|
||||
selectedKind.value = definition.kind
|
||||
if (!definition.supportedSizes.includes(selectedSize.value))
|
||||
selectedSize.value = definition.defaultSize
|
||||
}
|
||||
|
||||
function addWidget(): void {
|
||||
emit('add', selectedKind.value, selectedSize.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.opened,
|
||||
(opened) => {
|
||||
if (!opened) query.value = ''
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-sheet
|
||||
:opened="opened"
|
||||
class="widget-picker-sheet"
|
||||
:colors="sheetColors"
|
||||
@backdropclick="emit('close')"
|
||||
>
|
||||
<k-page
|
||||
class="widget-picker-page"
|
||||
:class="{ 'widget-picker-page--dark': phone.isDarkMode }"
|
||||
>
|
||||
<k-navbar :title="phone.t('Home.widgetSystem.galleryTitle')">
|
||||
<template #right>
|
||||
<k-link component="button" type="button" @click="emit('close')">
|
||||
{{ phone.t('Common.done') }}
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
|
||||
<div class="widget-picker-scroll">
|
||||
<k-searchbar
|
||||
:value="query"
|
||||
:placeholder="phone.t('Home.widgetSystem.search')"
|
||||
@input="query = inputValue($event)"
|
||||
@clear="query = ''"
|
||||
/>
|
||||
|
||||
<section class="widget-picker-preview">
|
||||
<SpringboardWidget
|
||||
:instance="previewInstance"
|
||||
preview
|
||||
:interactive="false"
|
||||
/>
|
||||
<h2>{{ phone.t(selectedDefinition.labelKey) }}</h2>
|
||||
<p>{{ phone.t(selectedDefinition.descriptionKey) }}</p>
|
||||
</section>
|
||||
|
||||
<section class="widget-picker-size">
|
||||
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
|
||||
<k-segmented raised>
|
||||
<k-segmented-button
|
||||
v-for="size in selectedDefinition.supportedSizes"
|
||||
:key="size"
|
||||
:active="selectedSize === size"
|
||||
@click="selectedSize = size"
|
||||
>
|
||||
{{ phone.t(`Home.widgetSystem.sizes.${size}`) }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</section>
|
||||
|
||||
<k-button large rounded class="widget-picker-add" @click="addWidget">
|
||||
{{ phone.t('Home.widgetSystem.addWidget') }}
|
||||
</k-button>
|
||||
|
||||
<section
|
||||
v-for="category in categories"
|
||||
:key="category.key"
|
||||
class="widget-picker-category"
|
||||
>
|
||||
<h3>{{ phone.t(category.key) }}</h3>
|
||||
<k-list inset strong>
|
||||
<k-list-item
|
||||
v-for="definition in category.widgets"
|
||||
:key="definition.kind"
|
||||
link
|
||||
link-component="button"
|
||||
:title="phone.t(definition.labelKey)"
|
||||
:subtitle="phone.t(definition.descriptionKey)"
|
||||
@click="selectWidget(definition)"
|
||||
>
|
||||
<template #media>
|
||||
<span class="widget-picker-icon">
|
||||
<component :is="icons[definition.kind]" :size="21" />
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="selectedKind === definition.kind" #after>
|
||||
<Check :size="20" class="widget-picker-check" />
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
</section>
|
||||
|
||||
<p v-if="filteredWidgets.length === 0" class="widget-picker-empty">
|
||||
{{ phone.t('Home.widgetSystem.noResults') }}
|
||||
</p>
|
||||
</div>
|
||||
</k-page>
|
||||
</k-sheet>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.widget-picker-sheet) {
|
||||
z-index: 110;
|
||||
height: calc(100% - 22px);
|
||||
overflow: hidden;
|
||||
border-radius: 28px 28px 0 0;
|
||||
}
|
||||
|
||||
.widget-picker-page {
|
||||
height: 100%;
|
||||
color: #111;
|
||||
background: #f2f2f7;
|
||||
}
|
||||
|
||||
.widget-picker-page--dark {
|
||||
color: #fff;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.widget-picker-scroll {
|
||||
height: calc(100% - 54px);
|
||||
padding: 8px 13px 42px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.widget-picker-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.widget-picker-preview {
|
||||
display: flex;
|
||||
min-height: 235px;
|
||||
padding: 22px 14px 14px;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.widget-picker-preview :deep(.home-widget-shell) {
|
||||
max-height: 188px;
|
||||
}
|
||||
|
||||
.widget-picker-preview h2 {
|
||||
margin: 14px 0 3px;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.4px;
|
||||
}
|
||||
|
||||
.widget-picker-preview p {
|
||||
max-width: 285px;
|
||||
margin: 0;
|
||||
color: #6e6e73;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.widget-picker-page--dark .widget-picker-preview p {
|
||||
color: #98989d;
|
||||
}
|
||||
|
||||
.widget-picker-size {
|
||||
display: grid;
|
||||
margin: 0 4px 14px;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.widget-picker-size > span {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.widget-picker-add {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.widget-picker-category h3 {
|
||||
margin: 18px 12px 7px;
|
||||
color: #6e6e73;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.widget-picker-category :deep([class*='title']) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.widget-picker-icon {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
background: #2c2c2e;
|
||||
}
|
||||
|
||||
.widget-picker-check {
|
||||
color: #0a84ff;
|
||||
}
|
||||
|
||||
.widget-picker-empty {
|
||||
padding: 40px 20px;
|
||||
color: #8e8e93;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -65,4 +65,7 @@ const formattedAmount = computed(() =>
|
||||
<style scoped>
|
||||
.citymarkt-offer{width:92%;padding:11px;border:1px solid #ffc92842;border-radius:14px;align-self:flex-start;background:linear-gradient(145deg,#332d19,var(--panel));box-shadow:0 7px 18px #0003}.citymarkt-offer--own{align-self:flex-end}.citymarkt-offer header{display:flex;align-items:center;gap:8px}.citymarkt-offer header>span{width:32px;height:32px;flex:none;border-radius:10px;display:grid;place-items:center;background:var(--yellow);color:#171816}.citymarkt-offer header>div{min-width:0;flex:1}.citymarkt-offer header small,.citymarkt-offer header strong{display:block}.citymarkt-offer header small{color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.02em;text-transform:uppercase}.citymarkt-offer header strong{margin-top:1px;font-size:18px}.citymarkt-offer header i{padding:5px 7px;border-radius:7px;background:#ffc92817;color:var(--yellow);font-size:8px;font-style:normal;font-weight:900;text-transform:uppercase}.citymarkt-offer>p{margin:7px 0 0;color:var(--muted);font-size:9px;line-height:1.35}.citymarkt-offer--accepted{border-color:#54d68173;background:linear-gradient(145deg,#193526,var(--panel))}.citymarkt-offer--accepted header>span{background:#54d681}.citymarkt-offer--accepted header i{background:#54d6811c;color:#67e494}.citymarkt-offer--rejected,.citymarkt-offer--countered{border-color:#ffffff14;filter:saturate(.65)}.citymarkt-offer--rejected header>span,.citymarkt-offer--countered header>span{background:#555750;color:#ddd}.citymarkt-offer--rejected header i,.citymarkt-offer--countered header i{background:#ffffff0c;color:var(--muted)}.citymarkt-offer__actions{margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:6px}.citymarkt-offer__actions button{min-height:35px;padding:7px 6px;border:1px solid #ffffff12;border-radius:10px;display:flex;align-items:center;justify-content:center;gap:4px;background:#ffffff09;color:inherit;font-size:9.5px;font-weight:850;line-height:1.1}.citymarkt-offer__actions button.accept{border:0;background:#54d681;color:#102319}.citymarkt-offer__actions button.reject{grid-column:1/-1;color:#ff8078}:global(.citymarkt--light) .citymarkt-offer{background:linear-gradient(145deg,#fff8d9,#fff);box-shadow:0 7px 18px #0001}:global(.citymarkt--light) .citymarkt-offer--accepted{background:linear-gradient(145deg,#e6faed,#fff)}
|
||||
.citymarkt-offer{padding:12px}.citymarkt-offer header>span{width:35px;height:35px}.citymarkt-offer header small{font-size:10.5px}.citymarkt-offer header strong{font-size:20px}.citymarkt-offer header i{padding:5px 8px;font-size:9.5px}.citymarkt-offer>p{margin-top:8px;font-size:11.5px}.citymarkt-offer__actions{margin-top:11px;gap:7px}.citymarkt-offer__actions button{min-height:40px;padding:8px;font-size:12px;font-weight:850;gap:5px}.citymarkt-offer__actions button svg{width:15px;height:15px}
|
||||
.citymarkt-offer:not(.citymarkt-offer--accepted) {
|
||||
border-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from 'lucide-vue-next'
|
||||
import { kGlass } from 'konsta/vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
type SelectOption = {
|
||||
@@ -20,7 +21,9 @@ const root = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const highlightedIndex = ref(0)
|
||||
const selectedLabel = computed(
|
||||
() => props.options.find((option) => option.value === props.modelValue)?.label ?? '',
|
||||
() =>
|
||||
props.options.find((option) => option.value === props.modelValue)?.label ??
|
||||
'',
|
||||
)
|
||||
|
||||
function open(): void {
|
||||
@@ -44,7 +47,8 @@ function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
if (!isOpen.value) open()
|
||||
else select(props.options[highlightedIndex.value]?.value ?? props.modelValue)
|
||||
else
|
||||
select(props.options[highlightedIndex.value]?.value ?? props.modelValue)
|
||||
return
|
||||
}
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
|
||||
@@ -52,7 +56,8 @@ function handleKeydown(event: KeyboardEvent): void {
|
||||
if (!isOpen.value) open()
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1
|
||||
highlightedIndex.value =
|
||||
(highlightedIndex.value + direction + props.options.length) % props.options.length
|
||||
(highlightedIndex.value + direction + props.options.length) %
|
||||
props.options.length
|
||||
}
|
||||
|
||||
function handleOutsidePointer(event: PointerEvent): void {
|
||||
@@ -60,12 +65,15 @@ function handleOutsidePointer(event: PointerEvent): void {
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('pointerdown', handleOutsidePointer))
|
||||
onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer))
|
||||
onUnmounted(() =>
|
||||
window.removeEventListener('pointerdown', handleOutsidePointer),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="citymarkt-select" @keydown="handleKeydown">
|
||||
<button
|
||||
<k-glass
|
||||
component="button"
|
||||
class="citymarkt-select__trigger"
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
@@ -74,7 +82,7 @@ onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer
|
||||
>
|
||||
<span>{{ selectedLabel }}</span>
|
||||
<ChevronDown :size="14" :class="{ open: isOpen }" />
|
||||
</button>
|
||||
</k-glass>
|
||||
|
||||
<Transition name="citymarkt-select">
|
||||
<div v-if="isOpen" class="citymarkt-select__menu" role="listbox">
|
||||
@@ -100,7 +108,105 @@ onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.citymarkt-select{position:relative;min-width:0}.citymarkt-select__trigger{width:100%;height:36px;padding:0 10px;border:1px solid #ffffff0d;border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:6px;background:var(--panel);color:inherit;font-size:10px;text-align:left}.citymarkt-select__trigger span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.citymarkt-select__trigger svg{flex:none;color:var(--yellow);transition:transform .18s ease}.citymarkt-select__trigger svg.open{transform:rotate(180deg)}.citymarkt-select__menu{position:absolute;z-index:12;top:calc(100% + 5px);right:0;left:0;max-height:176px;padding:4px;border:1px solid #ffffff16;border-radius:11px;overflow-y:auto;background:#292a27;box-shadow:0 12px 28px #0009;scrollbar-width:none}:global(.citymarkt--light) .citymarkt-select__menu{border-color:#00000014;background:#fff;box-shadow:0 12px 28px #0003}.citymarkt-select__menu button{width:100%;min-height:31px;padding:6px 7px;border:0;border-radius:8px;display:flex;align-items:center;justify-content:space-between;gap:5px;background:none;color:var(--muted);font-size:10px;text-align:left}.citymarkt-select__menu button.highlighted{background:#ffffff0b;color:inherit}:global(.citymarkt--light) .citymarkt-select__menu button.highlighted{background:#0000000b}.citymarkt-select__menu button.selected{color:var(--yellow);font-weight:800}.citymarkt-select-enter-active,.citymarkt-select-leave-active{transition:opacity .15s ease,transform .15s ease}.citymarkt-select-enter-from,.citymarkt-select-leave-to{opacity:0;transform:translateY(-4px) scale(.98)}
|
||||
.citymarkt-select__trigger{height:40px;font-size:13px}
|
||||
.citymarkt-select__menu button{min-height:36px;padding:7px 8px;font-size:12px}
|
||||
.citymarkt-select {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.citymarkt-select__trigger {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.citymarkt-select__trigger span {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.citymarkt-select__trigger svg {
|
||||
flex: none;
|
||||
color: var(--yellow);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.citymarkt-select__trigger svg.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.citymarkt-select__menu {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: calc(100% + 5px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
max-height: 176px;
|
||||
padding: 4px;
|
||||
border: 1px solid #ffffff14;
|
||||
border-radius: 11px;
|
||||
overflow-y: auto;
|
||||
background: #292a27;
|
||||
box-shadow: 0 12px 28px #0009;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
:global(.citymarkt--light) .citymarkt-select__menu {
|
||||
border-color: #00000012;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 28px #0003;
|
||||
}
|
||||
.citymarkt-select__menu button {
|
||||
width: 100%;
|
||||
min-height: 31px;
|
||||
padding: 6px 7px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.citymarkt-select__menu button.highlighted {
|
||||
background: #ffffff0b;
|
||||
color: inherit;
|
||||
}
|
||||
:global(.citymarkt--light) .citymarkt-select__menu button.highlighted {
|
||||
background: #0000000b;
|
||||
}
|
||||
.citymarkt-select__menu button.selected {
|
||||
background: #ffffff14;
|
||||
color: inherit;
|
||||
font-weight: 800;
|
||||
}
|
||||
:global(.citymarkt--light) .citymarkt-select__menu button.selected {
|
||||
background: #0000000d;
|
||||
}
|
||||
.citymarkt-select-enter-active,
|
||||
.citymarkt-select-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
.citymarkt-select-enter-from,
|
||||
.citymarkt-select-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
}
|
||||
.citymarkt-select__trigger {
|
||||
height: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.citymarkt-select__menu button {
|
||||
min-height: 36px;
|
||||
padding: 7px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('app registry', () => {
|
||||
PHONE_APPS.filter((app) => app.category === 'social').map(
|
||||
(app) => app.id,
|
||||
),
|
||||
).toEqual(['local-pages', 'radio', 'phone', 'darkchat', 'mail'])
|
||||
).toEqual(['local-pages', 'radio', 'phone', 'darkchat', 'banking', 'mail'])
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
|
||||
@@ -174,7 +174,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
route: '/apps/map',
|
||||
},
|
||||
{
|
||||
category: 'utilities',
|
||||
category: 'social',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/BankingApp.vue')),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { WidgetDefinition, WidgetKind } from '@/types/widgets'
|
||||
|
||||
export const WIDGET_REGISTRY: WidgetDefinition[] = [
|
||||
{
|
||||
categoryKey: 'Home.widgetSystem.categories.essentials',
|
||||
configurable: true,
|
||||
defaultSize: 'small',
|
||||
descriptionKey: 'Home.widgetSystem.clock.description',
|
||||
kind: 'clock',
|
||||
labelKey: 'Home.widgetSystem.clock.name',
|
||||
supportedSizes: ['small', 'medium'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'Home.widgetSystem.categories.essentials',
|
||||
configurable: false,
|
||||
defaultSize: 'small',
|
||||
descriptionKey: 'Home.widgetSystem.date.description',
|
||||
kind: 'date',
|
||||
labelKey: 'Home.widgetSystem.date.name',
|
||||
supportedSizes: ['small', 'medium'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'Home.widgetSystem.categories.information',
|
||||
configurable: false,
|
||||
defaultSize: 'medium',
|
||||
descriptionKey: 'Home.widgetSystem.weather.description',
|
||||
kind: 'weather',
|
||||
labelKey: 'Home.widgetSystem.weather.name',
|
||||
supportedSizes: ['small', 'medium', 'large'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'Home.widgetSystem.categories.media',
|
||||
configurable: false,
|
||||
defaultSize: 'medium',
|
||||
descriptionKey: 'Home.widgetSystem.music.description',
|
||||
kind: 'music',
|
||||
labelKey: 'Home.widgetSystem.music.name',
|
||||
supportedSizes: ['medium', 'large'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'Home.widgetSystem.categories.finance',
|
||||
configurable: true,
|
||||
defaultSize: 'small',
|
||||
descriptionKey: 'Home.widgetSystem.wallet.description',
|
||||
kind: 'wallet',
|
||||
labelKey: 'Home.widgetSystem.wallet.name',
|
||||
supportedSizes: ['small', 'medium'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'Home.widgetSystem.categories.finance',
|
||||
configurable: false,
|
||||
defaultSize: 'medium',
|
||||
descriptionKey: 'Home.widgetSystem.transactions.description',
|
||||
kind: 'transactions',
|
||||
labelKey: 'Home.widgetSystem.transactions.name',
|
||||
supportedSizes: ['medium', 'large'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'Home.widgetSystem.categories.people',
|
||||
configurable: true,
|
||||
defaultSize: 'medium',
|
||||
descriptionKey: 'Home.widgetSystem.contacts.description',
|
||||
kind: 'contacts',
|
||||
labelKey: 'Home.widgetSystem.contacts.name',
|
||||
supportedSizes: ['medium', 'large'],
|
||||
},
|
||||
]
|
||||
|
||||
export const WIDGET_REGISTRY_BY_KIND = new Map<WidgetKind, WidgetDefinition>(
|
||||
WIDGET_REGISTRY.map((definition) => [definition.kind, definition]),
|
||||
)
|
||||
@@ -20,6 +20,10 @@ describe('neon drop engine', () => {
|
||||
it('starts with a valid piece and a complete seven-piece bag', () => {
|
||||
const game = createNeonDropGame(() => 0.5)
|
||||
expect(game.status).toBe('playing')
|
||||
expect(game.board).toHaveLength(17)
|
||||
expect(game.board.every((row) => row.length === NEON_DROP_COLUMNS)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(new Set([game.active.kind, ...game.queue]).size).toBe(7)
|
||||
expect(canPlaceNeonDropPiece(game.board, game.active)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from './types'
|
||||
|
||||
export const NEON_DROP_COLUMNS = 10
|
||||
export const NEON_DROP_ROWS = 18
|
||||
export const NEON_DROP_ROWS = 17
|
||||
export const NEON_DROP_LINES_PER_LEVEL = 8
|
||||
|
||||
const PIECE_KINDS: NeonDropPieceKind[] = ['I', 'J', 'L', 'O', 'S', 'T', 'Z']
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createSnakeGame,
|
||||
SNAKE_BOARD_HEIGHT,
|
||||
SNAKE_BOARD_WIDTH,
|
||||
stepSnake,
|
||||
turnSnake,
|
||||
@@ -27,6 +28,17 @@ describe('Snake engine', () => {
|
||||
expect(next.body).not.toContainEqual(next.fruit)
|
||||
})
|
||||
|
||||
it('places fruit inside the visible play area', () => {
|
||||
for (const random of [() => 0, () => 1]) {
|
||||
const { fruit } = createSnakeGame(random)
|
||||
|
||||
expect(fruit.x).toBeGreaterThanOrEqual(0)
|
||||
expect(fruit.x).toBeLessThan(SNAKE_BOARD_WIDTH)
|
||||
expect(fruit.y).toBeGreaterThanOrEqual(0)
|
||||
expect(fruit.y).toBeLessThan(SNAKE_BOARD_HEIGHT)
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores an immediate reverse direction', () => {
|
||||
const game = createSnakeGame(() => 0)
|
||||
|
||||
@@ -46,6 +58,20 @@ describe('Snake engine', () => {
|
||||
expect(stepSnake(game).status).toBe('game-over')
|
||||
})
|
||||
|
||||
it('ends the game at the lower edge of the visible play area', () => {
|
||||
const game: SnakeGameState = {
|
||||
...createSnakeGame(() => 0),
|
||||
body: [
|
||||
{ x: 4, y: SNAKE_BOARD_HEIGHT - 1 },
|
||||
{ x: 4, y: SNAKE_BOARD_HEIGHT - 2 },
|
||||
],
|
||||
direction: 'down',
|
||||
pendingDirection: 'down',
|
||||
}
|
||||
|
||||
expect(stepSnake(game).status).toBe('game-over')
|
||||
})
|
||||
|
||||
it('ends the game on body collision', () => {
|
||||
const game: SnakeGameState = {
|
||||
body: [
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
} from './types'
|
||||
|
||||
export const SNAKE_BOARD_WIDTH = 16
|
||||
export const SNAKE_BOARD_HEIGHT = 18
|
||||
export const SNAKE_BOARD_HEIGHT = 30
|
||||
|
||||
const DIRECTION_VECTORS: Record<SnakeDirection, SnakePoint> = {
|
||||
up: { x: 0, y: -1 },
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { useBankingStore } from '@/stores/banking'
|
||||
import { useCallsStore } from '@/stores/calls'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
|
||||
const now = ref(new Date())
|
||||
let clockConsumers = 0
|
||||
let clockInterval: number | undefined
|
||||
|
||||
const tracks = [
|
||||
{ artist: 'Sky Radio', title: 'Night Drive' },
|
||||
{ artist: 'Los Santos FM', title: 'Pacific Coast' },
|
||||
{ artist: 'Mirror Park', title: 'After Hours' },
|
||||
]
|
||||
const trackIndex = ref(0)
|
||||
const playing = ref(false)
|
||||
|
||||
export function useClockService() {
|
||||
const phone = usePhoneStore()
|
||||
onMounted(() => {
|
||||
clockConsumers += 1
|
||||
if (clockInterval === undefined) {
|
||||
clockInterval = window.setInterval(() => {
|
||||
now.value = new Date()
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
clockConsumers -= 1
|
||||
if (clockConsumers === 0 && clockInterval !== undefined) {
|
||||
window.clearInterval(clockInterval)
|
||||
clockInterval = undefined
|
||||
}
|
||||
})
|
||||
return {
|
||||
date: computed(() =>
|
||||
new Intl.DateTimeFormat(phone.lang, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
weekday: 'long',
|
||||
}).format(now.value),
|
||||
),
|
||||
day: computed(() => now.value.getDate()),
|
||||
month: computed(() =>
|
||||
new Intl.DateTimeFormat(phone.lang, { month: 'long' }).format(now.value),
|
||||
),
|
||||
time: computed(() =>
|
||||
new Intl.DateTimeFormat(phone.lang, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(now.value),
|
||||
),
|
||||
weekday: computed(() =>
|
||||
new Intl.DateTimeFormat(phone.lang, { weekday: 'long' }).format(
|
||||
now.value,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function useWeatherService() {
|
||||
const phone = usePhoneStore()
|
||||
const weather = useWeatherStore()
|
||||
return {
|
||||
condition: computed(() =>
|
||||
weather.forecast
|
||||
? phone.t(`Apps.weather.conditions.${weather.forecast.condition}`)
|
||||
: phone.t('Common.loading'),
|
||||
),
|
||||
forecast: computed(() => weather.forecast),
|
||||
location: computed(() =>
|
||||
phone.t(
|
||||
`Apps.weather.regions.${weather.forecast?.region ?? 'los_santos'}`,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function useMusicService() {
|
||||
return {
|
||||
current: computed(() => tracks[trackIndex.value]),
|
||||
next(): void {
|
||||
trackIndex.value = (trackIndex.value + 1) % tracks.length
|
||||
playing.value = true
|
||||
},
|
||||
playing,
|
||||
toggle(): void {
|
||||
playing.value = !playing.value
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useBankService() {
|
||||
const banking = useBankingStore()
|
||||
const overview = computed(
|
||||
() =>
|
||||
banking.overview ?? {
|
||||
bank: 24_580,
|
||||
cash: 1_240,
|
||||
currency: '$',
|
||||
playerId: 0,
|
||||
playerName: 'Sky Citizen',
|
||||
transactions: [
|
||||
{
|
||||
amount: 1800,
|
||||
createdAt: Date.now() - 3_600_000,
|
||||
id: -1,
|
||||
kind: 'deposit' as const,
|
||||
label: 'Salary',
|
||||
reference: 'PAYROLL',
|
||||
},
|
||||
{
|
||||
amount: 86,
|
||||
createdAt: Date.now() - 7_200_000,
|
||||
id: -2,
|
||||
kind: 'withdrawal' as const,
|
||||
label: 'Fuel',
|
||||
reference: 'CARD',
|
||||
},
|
||||
{
|
||||
amount: 340,
|
||||
createdAt: Date.now() - 18_000_000,
|
||||
id: -3,
|
||||
kind: 'transfer_out' as const,
|
||||
label: 'Transfer',
|
||||
reference: 'PHONE',
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
onMounted(() => {
|
||||
if (!banking.overview && !banking.isLoading) void banking.load()
|
||||
})
|
||||
return { overview }
|
||||
}
|
||||
|
||||
export function useContactsService() {
|
||||
const calls = useCallsStore()
|
||||
const contacts = computed(() =>
|
||||
calls.contacts.length
|
||||
? calls.contacts
|
||||
: [
|
||||
{ id: 'mock-nova', name: 'Nova', phone_number: '555-0142' },
|
||||
{ id: 'mock-alex', name: 'Alex', phone_number: '555-0198' },
|
||||
{ id: 'mock-mia', name: 'Mia', phone_number: '555-0121' },
|
||||
{ id: 'mock-liam', name: 'Liam', phone_number: '555-0177' },
|
||||
],
|
||||
)
|
||||
onMounted(() => {
|
||||
if (!calls.contacts.length) void calls.loadContacts()
|
||||
})
|
||||
return { contacts }
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import {
|
||||
addHomePage,
|
||||
createDefaultHomeLayout,
|
||||
deleteHomePage,
|
||||
moveHomeApp,
|
||||
parseHomeLayout,
|
||||
removeHomeApp,
|
||||
@@ -39,6 +41,20 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
|
||||
}),
|
||||
actions: {
|
||||
addHomePage(): boolean {
|
||||
const next = addHomePage(this.homeLayout)
|
||||
if (next === this.homeLayout) return false
|
||||
this.homeLayout = next
|
||||
this.persist()
|
||||
return true
|
||||
},
|
||||
deleteHomePage(page: number): boolean {
|
||||
const next = deleteHomePage(this.homeLayout, page)
|
||||
if (next === this.homeLayout) return false
|
||||
this.homeLayout = next
|
||||
this.persist()
|
||||
return true
|
||||
},
|
||||
claimApp(id: LaunchablePhoneAppId): void {
|
||||
if (!this.claimedApps.includes(id)) {
|
||||
this.claimedApps.push(id)
|
||||
|
||||
@@ -31,4 +31,31 @@ describe('message media handoff', () => {
|
||||
expect(store.request).toMatchObject({ mediaType: 'video', target: '4205550196' })
|
||||
expect(store.cancel()).toBe('/apps/messages')
|
||||
})
|
||||
|
||||
it('returns multiple photos and the requesting app context', () => {
|
||||
const store = useMessageMediaStore()
|
||||
const secondPhoto = { ...photo, id: 18 }
|
||||
store.begin('citymarkt:sell', 'photo', '/apps/citymarkt?sell=1', 2, {
|
||||
title: 'Draft listing',
|
||||
})
|
||||
|
||||
expect(store.completeMany([photo, secondPhoto])).toBe('/apps/citymarkt?sell=1')
|
||||
expect(store.consumeMany<{ title: string }>('citymarkt:sell')).toEqual({
|
||||
context: { title: 'Draft listing' },
|
||||
media: [photo, secondPhoto],
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the requesting app context when selection is cancelled', () => {
|
||||
const store = useMessageMediaStore()
|
||||
store.begin('local-pages:compose', 'photo', '/apps/local-pages?compose=1', 6, {
|
||||
title: 'Draft post',
|
||||
})
|
||||
|
||||
expect(store.cancel()).toBe('/apps/local-pages?compose=1')
|
||||
expect(store.consumeMany<{ title: string }>('local-pages:compose')).toEqual({
|
||||
context: { title: 'Draft post' },
|
||||
media: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,13 +3,20 @@ import { defineStore } from 'pinia'
|
||||
import type { MediaType, PhoneMedia } from '@/types/media'
|
||||
|
||||
type MessageMediaRequest = {
|
||||
context?: unknown
|
||||
maxSelection: number
|
||||
mediaType: MediaType
|
||||
returnPath: string
|
||||
target: string
|
||||
}
|
||||
|
||||
type MessageMediaResult = MessageMediaRequest & {
|
||||
media: PhoneMedia
|
||||
media: PhoneMedia[]
|
||||
}
|
||||
|
||||
export type MediaSelectionResult<T = unknown> = {
|
||||
context?: T
|
||||
media: PhoneMedia[]
|
||||
}
|
||||
|
||||
export const useMessageMediaStore = defineStore('message-media', {
|
||||
@@ -22,17 +29,37 @@ export const useMessageMediaStore = defineStore('message-media', {
|
||||
target: string,
|
||||
mediaType: MediaType,
|
||||
returnPath = '/apps/messages',
|
||||
maxSelection = 1,
|
||||
context?: unknown,
|
||||
): void {
|
||||
this.request = { mediaType, returnPath, target }
|
||||
this.request = {
|
||||
context,
|
||||
maxSelection: Math.max(1, Math.floor(maxSelection)),
|
||||
mediaType,
|
||||
returnPath,
|
||||
target,
|
||||
}
|
||||
this.result = null
|
||||
},
|
||||
cancel(): string {
|
||||
const returnPath = this.request?.returnPath ?? '/apps/messages'
|
||||
if (this.request) this.result = { ...this.request, media: [] }
|
||||
this.request = null
|
||||
return returnPath
|
||||
},
|
||||
complete(media: PhoneMedia): string | null {
|
||||
if (!this.request || this.request.mediaType !== media.mediaType) return null
|
||||
return this.completeMany([media])
|
||||
},
|
||||
completeMany(media: PhoneMedia[]): string | null {
|
||||
if (
|
||||
!this.request ||
|
||||
media.length < 1 ||
|
||||
media.length > this.request.maxSelection ||
|
||||
media.some((entry) => entry.mediaType !== this.request?.mediaType)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const returnPath = this.request.returnPath
|
||||
this.result = { ...this.request, media }
|
||||
this.request = null
|
||||
@@ -40,9 +67,18 @@ export const useMessageMediaStore = defineStore('message-media', {
|
||||
},
|
||||
consume(target: string): PhoneMedia | null {
|
||||
if (!this.result || this.result.target !== target) return null
|
||||
const media = this.result.media
|
||||
const media = this.result.media[0] ?? null
|
||||
this.result = null
|
||||
return media
|
||||
},
|
||||
consumeMany<T = unknown>(target: string): MediaSelectionResult<T> | null {
|
||||
if (!this.result || this.result.target !== target) return null
|
||||
const result = {
|
||||
context: this.result.context as T | undefined,
|
||||
media: [...this.result.media],
|
||||
}
|
||||
this.result = null
|
||||
return result
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({
|
||||
nuiCall: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
|
||||
describe('phone passcode store', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
matchMedia: vi.fn(() => ({ matches: false })),
|
||||
})
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('stores the server security state after setting a six digit passcode', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: {
|
||||
security: { enabled: true, length: 6, lockedUntil: 0 },
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const response = await phone.setPasscode('123456')
|
||||
|
||||
expect(response.success).toBe(true)
|
||||
expect(phone.security).toEqual({
|
||||
enabled: true,
|
||||
length: 6,
|
||||
lockedUntil: 0,
|
||||
})
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('security:set-passcode', {
|
||||
passcode: '123456',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the configured state after a rejected unlock attempt', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
error: 'invalid_passcode',
|
||||
success: false,
|
||||
})
|
||||
|
||||
const phone = usePhoneStore()
|
||||
phone.security = { enabled: true, length: 4, lockedUntil: 0 }
|
||||
await phone.unlockWithPasscode('9999')
|
||||
|
||||
expect(phone.security).toEqual({
|
||||
enabled: true,
|
||||
length: 4,
|
||||
lockedUntil: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the security state after disabling the passcode', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({
|
||||
data: {
|
||||
security: { enabled: false, length: null, lockedUntil: 0 },
|
||||
},
|
||||
success: true,
|
||||
})
|
||||
|
||||
const phone = usePhoneStore()
|
||||
phone.security = { enabled: true, length: 4, lockedUntil: 0 }
|
||||
await phone.disablePasscode('1234')
|
||||
|
||||
expect(phone.security.enabled).toBe(false)
|
||||
expect(phone.security.length).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,15 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type { AppLaunchOrigin, LaunchablePhoneAppId } from '@/types/apps'
|
||||
import type { DeviceBootstrap, PhoneDevice } from '@/types/device'
|
||||
import type {
|
||||
DeviceBootstrap,
|
||||
DeviceSecurity,
|
||||
PhoneDevice,
|
||||
} from '@/types/device'
|
||||
import { clampPage } from '@/utils/pages'
|
||||
import { cloneJsonData } from '@/utils/clone'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import type { NuiResponse } from '@/utils/nui'
|
||||
import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
parsePhonePreferences,
|
||||
@@ -15,12 +20,19 @@ import {
|
||||
|
||||
type LocaleTree = Record<string, unknown>
|
||||
|
||||
export type PasscodeResponseData = {
|
||||
attemptsRemaining?: number
|
||||
retryAfter?: number
|
||||
security?: DeviceSecurity
|
||||
}
|
||||
|
||||
export type PhoneOpenPayload = {
|
||||
account?: DeviceBootstrap['account']
|
||||
device?: PhoneDevice
|
||||
lang?: string
|
||||
locales?: LocaleTree
|
||||
notes?: DeviceBootstrap['notes']
|
||||
security?: DeviceSecurity
|
||||
token?: string
|
||||
}
|
||||
|
||||
@@ -800,6 +812,8 @@ const defaultLocales: LocaleTree = {
|
||||
sortPriceDesc: 'Highest price',
|
||||
freshOffers: 'Fresh offers',
|
||||
offers: 'offers',
|
||||
compactView: 'Compact view',
|
||||
largeView: 'Large view',
|
||||
noListings: 'No offers found',
|
||||
noListingsBody: 'Try another search or category.',
|
||||
noDistrict: 'No district',
|
||||
@@ -1376,6 +1390,7 @@ const defaultLocales: LocaleTree = {
|
||||
notifications: 'Notifications',
|
||||
sounds: 'Sounds & Haptics',
|
||||
general: 'General Settings',
|
||||
security: 'Passcode & Security',
|
||||
appearance: 'Appearance',
|
||||
allowNotifications: 'Allow Notifications',
|
||||
notificationSounds: 'Sounds',
|
||||
@@ -1420,6 +1435,28 @@ const defaultLocales: LocaleTree = {
|
||||
'This removes the account and all local data from this phone. Cloud data and the IMEI remain.',
|
||||
factoryResetProgress: 'Erasing iFruit Phone',
|
||||
factoryResetWarning: 'Do not turn off this phone. This takes 60 seconds.',
|
||||
passcode: {
|
||||
description:
|
||||
'A passcode protects the contents of this phone. It stays with the device when the SIM or iFruit account changes.',
|
||||
status: 'Passcode',
|
||||
codeLength: 'Code Length',
|
||||
sixDigit: '6-Digit Code',
|
||||
fourDigit: '4-Digit Code',
|
||||
turnOn: 'Turn Passcode On',
|
||||
turnOff: 'Turn Passcode Off',
|
||||
change: 'Change Passcode',
|
||||
enterNew: 'Enter New Passcode',
|
||||
confirmNew: 'Verify New Passcode',
|
||||
enterCurrent: 'Enter Current Passcode',
|
||||
screenSubtitle: 'Use 4 or 6 numbers.',
|
||||
incorrect: 'Incorrect passcode.',
|
||||
mismatch: 'The passcodes did not match.',
|
||||
locked: 'Too many incorrect attempts. Try again later.',
|
||||
rateLimited: 'Too many attempts. Please wait.',
|
||||
failed: 'The passcode could not be updated.',
|
||||
saved: 'Passcode saved.',
|
||||
disabled: 'Passcode turned off.',
|
||||
},
|
||||
accountErrors: {
|
||||
invalid_email: 'Choose a valid 3–32 character iFruit address.',
|
||||
invalid_password: 'Password must be 6–64 characters.',
|
||||
@@ -1514,6 +1551,16 @@ const defaultLocales: LocaleTree = {
|
||||
flashlight: 'Flashlight',
|
||||
camera: 'Camera',
|
||||
swipeUp: 'Swipe up to open',
|
||||
passcode: {
|
||||
enter: 'Enter Passcode',
|
||||
unlockSubtitle: 'Enter the passcode for this phone.',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete digit',
|
||||
incorrect: 'Incorrect passcode',
|
||||
locked: 'Too many attempts. Try again in {seconds} seconds.',
|
||||
tryAgain: 'Try again in {seconds} seconds',
|
||||
rateLimited: 'Too many attempts. Please wait.',
|
||||
},
|
||||
},
|
||||
Home: {
|
||||
appLibrary: 'App Library',
|
||||
@@ -1523,6 +1570,10 @@ const defaultLocales: LocaleTree = {
|
||||
dock: 'Dock',
|
||||
noApps: 'No apps found',
|
||||
removeApp: 'Remove {app} from Home Screen',
|
||||
addToHome: 'Add {app} to Home Screen',
|
||||
addPage: 'Add Home Screen page',
|
||||
deletePage: 'Delete current Home Screen page',
|
||||
removedFromHome: 'Removed from Home Screen',
|
||||
page: 'Page',
|
||||
pages: 'Home screen pages',
|
||||
groups: {
|
||||
@@ -1546,6 +1597,59 @@ const defaultLocales: LocaleTree = {
|
||||
pause: 'Pause',
|
||||
},
|
||||
},
|
||||
widgetSystem: {
|
||||
galleryTitle: 'Widgets',
|
||||
search: 'Search Widgets',
|
||||
size: 'Size',
|
||||
add: 'Add Widget',
|
||||
addWidget: 'Add Widget',
|
||||
editWidget: 'Edit Widget',
|
||||
removeWidget: 'Remove Widget',
|
||||
remove: 'Remove widget',
|
||||
configure: 'Configure Widget',
|
||||
noResults: 'No widgets found',
|
||||
sizes: { small: 'Small', medium: 'Medium', large: 'Large' },
|
||||
categories: {
|
||||
essentials: 'Essentials',
|
||||
information: 'Information',
|
||||
media: 'Media',
|
||||
finance: 'Finance',
|
||||
people: 'People',
|
||||
},
|
||||
clock: {
|
||||
name: 'Clock',
|
||||
description: 'The current time, with an optional date.',
|
||||
showDate: 'Show Date',
|
||||
},
|
||||
date: {
|
||||
name: 'Date',
|
||||
description: 'Weekday, month, and date at a glance.',
|
||||
},
|
||||
weather: {
|
||||
name: 'Weather',
|
||||
description: 'Current conditions, location, and high and low.',
|
||||
},
|
||||
music: {
|
||||
name: 'Now Playing',
|
||||
description: 'Music controls and the current track.',
|
||||
},
|
||||
wallet: {
|
||||
name: 'Wallet',
|
||||
description: 'Your current bank or cash balance.',
|
||||
balance: 'Displayed Balance',
|
||||
bank: 'Bank',
|
||||
cash: 'Cash',
|
||||
},
|
||||
transactions: {
|
||||
name: 'Transactions',
|
||||
description: 'Your latest incoming and outgoing payments.',
|
||||
},
|
||||
contacts: {
|
||||
name: 'Favorites',
|
||||
description: 'Call or message your favorite contacts.',
|
||||
choose: 'Favorite Contacts',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1572,6 +1676,11 @@ export const usePhoneStore = defineStore('phone', {
|
||||
launchOrigin: null as AppLaunchOrigin | null,
|
||||
locales: defaultLocales,
|
||||
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
|
||||
security: {
|
||||
enabled: false,
|
||||
length: null,
|
||||
lockedUntil: 0,
|
||||
} as DeviceSecurity,
|
||||
systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
}),
|
||||
getters: {
|
||||
@@ -1590,6 +1699,11 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.lang = payload.lang ?? 'en'
|
||||
this.locales = payload.locales ?? defaultLocales
|
||||
if (payload.device) this.hydrateDevice(payload.device)
|
||||
this.security = payload.security ?? {
|
||||
enabled: false,
|
||||
length: null,
|
||||
lockedUntil: 0,
|
||||
}
|
||||
this.isOpen = true
|
||||
},
|
||||
hydrateDevice(device: PhoneDevice): void {
|
||||
@@ -1659,6 +1773,55 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.preferences.settings.wallpaper = wallpaper
|
||||
this.saveDeviceNamespace('settings', this.preferences)
|
||||
},
|
||||
async unlockWithPasscode(
|
||||
passcode: string,
|
||||
): Promise<NuiResponse<PasscodeResponseData>> {
|
||||
const response = await nuiCall<PasscodeResponseData>(
|
||||
'security:unlock',
|
||||
{ passcode },
|
||||
)
|
||||
if (response.success && response.data?.security) {
|
||||
this.security = response.data.security
|
||||
}
|
||||
return response
|
||||
},
|
||||
async setPasscode(
|
||||
passcode: string,
|
||||
): Promise<NuiResponse<PasscodeResponseData>> {
|
||||
const response = await nuiCall<PasscodeResponseData>(
|
||||
'security:set-passcode',
|
||||
{ passcode },
|
||||
)
|
||||
if (response.success && response.data?.security) {
|
||||
this.security = response.data.security
|
||||
}
|
||||
return response
|
||||
},
|
||||
async changePasscode(
|
||||
currentPasscode: string,
|
||||
newPasscode: string,
|
||||
): Promise<NuiResponse<PasscodeResponseData>> {
|
||||
const response = await nuiCall<PasscodeResponseData>(
|
||||
'security:change-passcode',
|
||||
{ currentPasscode, newPasscode },
|
||||
)
|
||||
if (response.success && response.data?.security) {
|
||||
this.security = response.data.security
|
||||
}
|
||||
return response
|
||||
},
|
||||
async disablePasscode(
|
||||
passcode: string,
|
||||
): Promise<NuiResponse<PasscodeResponseData>> {
|
||||
const response = await nuiCall<PasscodeResponseData>(
|
||||
'security:disable-passcode',
|
||||
{ passcode },
|
||||
)
|
||||
if (response.success && response.data?.security) {
|
||||
this.security = response.data.security
|
||||
}
|
||||
return response
|
||||
},
|
||||
t(path: string, replacements: Record<string, string> = {}): string {
|
||||
const translated = getByPath(this.locales, path)
|
||||
const fallback = getByPath(defaultLocales, path)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
|
||||
import {
|
||||
addWidget,
|
||||
createDefaultWidgetLayout,
|
||||
deleteWidgetPage,
|
||||
moveWidget,
|
||||
parseWidgetLayout,
|
||||
removeWidget,
|
||||
resizeWidget,
|
||||
updateWidgetSettings,
|
||||
} from '@/utils/widgetLayout'
|
||||
|
||||
export const useWidgetsStore = defineStore('widgets', {
|
||||
state: () => ({
|
||||
layout: createDefaultWidgetLayout(),
|
||||
}),
|
||||
actions: {
|
||||
add(kind: WidgetKind, size: WidgetSize, page: number): string | null {
|
||||
const previousIds = new Set(
|
||||
this.layout.instances.map((instance) => instance.id),
|
||||
)
|
||||
const next = addWidget(this.layout, kind, size, page)
|
||||
if (next === this.layout) return null
|
||||
this.layout = next
|
||||
this.persist()
|
||||
return (
|
||||
this.layout.instances.find((instance) => !previousIds.has(instance.id))
|
||||
?.id ?? null
|
||||
)
|
||||
},
|
||||
hydrate(payload: unknown): void {
|
||||
this.layout = parseWidgetLayout(payload)
|
||||
},
|
||||
deletePage(page: number, maximumPage: number): boolean {
|
||||
const next = deleteWidgetPage(this.layout, page, maximumPage)
|
||||
if (next === this.layout) return false
|
||||
this.layout = next
|
||||
this.persist()
|
||||
return true
|
||||
},
|
||||
move(id: string, page: number, column: number, row: number): void {
|
||||
const next = moveWidget(this.layout, id, page, column, row)
|
||||
if (next === this.layout) return
|
||||
this.layout = next
|
||||
this.persist()
|
||||
},
|
||||
remove(id: string): void {
|
||||
const next = removeWidget(this.layout, id)
|
||||
if (next.instances.length === this.layout.instances.length) return
|
||||
this.layout = next
|
||||
this.persist()
|
||||
},
|
||||
resize(id: string, size: WidgetSize): void {
|
||||
const next = resizeWidget(this.layout, id, size)
|
||||
if (next === this.layout) return
|
||||
this.layout = next
|
||||
this.persist()
|
||||
},
|
||||
updateSettings(id: string, settings: WidgetSettings): void {
|
||||
this.layout = updateWidgetSettings(this.layout, id, settings)
|
||||
this.persist()
|
||||
},
|
||||
persist(): void {
|
||||
usePhoneStore().saveDeviceNamespace('widgets', this.layout)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -19,6 +19,12 @@ export type PhoneNotificationDevicePayload = {
|
||||
settings?: string | null
|
||||
}
|
||||
|
||||
export type DeviceSecurity = {
|
||||
enabled: boolean
|
||||
length: 4 | 6 | null
|
||||
lockedUntil: number
|
||||
}
|
||||
|
||||
export type AccountDevice = {
|
||||
created_at: string
|
||||
current: boolean
|
||||
@@ -37,5 +43,6 @@ export type DeviceBootstrap = {
|
||||
account: IfruitAccount | null
|
||||
device: PhoneDevice
|
||||
notes: Note[]
|
||||
security: DeviceSecurity
|
||||
token: string
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ export type MarketplaceImage = {
|
||||
|
||||
export type MarketplaceListingSummary = {
|
||||
category: MarketplaceCategory
|
||||
created_at: string
|
||||
created_at: DatabaseDateValue
|
||||
district: string | null
|
||||
expires_at: string
|
||||
expires_at: DatabaseDateValue
|
||||
id: string
|
||||
image: string | null
|
||||
is_favorite: boolean | number
|
||||
@@ -35,7 +35,7 @@ export type MarketplaceListingSummary = {
|
||||
seller_name: string
|
||||
status: MarketplaceStatus
|
||||
title: string
|
||||
updated_at: string
|
||||
updated_at: DatabaseDateValue
|
||||
}
|
||||
|
||||
export type MarketplaceListing = MarketplaceListingSummary & {
|
||||
@@ -46,7 +46,7 @@ export type MarketplaceListing = MarketplaceListingSummary & {
|
||||
reserved_account_id: number | null
|
||||
revision: number
|
||||
seller_active: number
|
||||
seller_since: string
|
||||
seller_since: DatabaseDateValue
|
||||
show_phone: boolean | number
|
||||
}
|
||||
|
||||
@@ -75,12 +75,12 @@ export type MarketplaceInquirySummary = {
|
||||
status: MarketplaceStatus
|
||||
title: string
|
||||
unread: number
|
||||
updated_at: string
|
||||
updated_at: DatabaseDateValue
|
||||
}
|
||||
|
||||
export type MarketplaceMessage = {
|
||||
body: string
|
||||
created_at: string
|
||||
created_at: DatabaseDateValue
|
||||
id: number
|
||||
read_at: string | null
|
||||
sender_account_id: number
|
||||
@@ -88,13 +88,13 @@ export type MarketplaceMessage = {
|
||||
|
||||
export type MarketplaceOffer = {
|
||||
amount: number | string
|
||||
created_at: string
|
||||
created_at: DatabaseDateValue
|
||||
id: number
|
||||
proposer_account_id: number
|
||||
read_at: string | null
|
||||
response_read_at: string | null
|
||||
status: MarketplaceOfferStatus
|
||||
updated_at: string
|
||||
updated_at: DatabaseDateValue
|
||||
}
|
||||
|
||||
export type MarketplaceInquiry = {
|
||||
@@ -124,3 +124,4 @@ export type MarketplaceChat = {
|
||||
}
|
||||
|
||||
export type MarketplaceCounts = { active: number; unread: number }
|
||||
import type { DatabaseDateValue } from '@/utils/date'
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export type WidgetKind =
|
||||
| 'clock'
|
||||
| 'date'
|
||||
| 'weather'
|
||||
| 'music'
|
||||
| 'wallet'
|
||||
| 'transactions'
|
||||
| 'contacts'
|
||||
|
||||
export type WidgetSize = 'small' | 'medium' | 'large'
|
||||
|
||||
export type WidgetSettings = {
|
||||
balanceSource?: 'bank' | 'cash'
|
||||
contactIds?: string[]
|
||||
showDate?: boolean
|
||||
}
|
||||
|
||||
export type WidgetInstance = {
|
||||
column: number
|
||||
id: string
|
||||
kind: WidgetKind
|
||||
page: number
|
||||
row: number
|
||||
settings: WidgetSettings
|
||||
size: WidgetSize
|
||||
}
|
||||
|
||||
export type WidgetLayout = {
|
||||
instances: WidgetInstance[]
|
||||
version: 1
|
||||
}
|
||||
|
||||
export type WidgetDefinition = {
|
||||
categoryKey: string
|
||||
configurable: boolean
|
||||
defaultSize: WidgetSize
|
||||
descriptionKey: string
|
||||
kind: WidgetKind
|
||||
labelKey: string
|
||||
supportedSizes: WidgetSize[]
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
addHomePage,
|
||||
createDefaultHomeLayout,
|
||||
deleteHomePage,
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
MAX_HOME_GRID_PAGES,
|
||||
moveHomeApp,
|
||||
parseHomeLayout,
|
||||
removeHomeApp,
|
||||
@@ -163,4 +166,24 @@ describe('home layout', () => {
|
||||
expect(restored.grid[0]).toBe('phone')
|
||||
expect(restored.hidden).not.toContain('phone')
|
||||
})
|
||||
|
||||
it('adds persistent empty pages up to the home screen limit', () => {
|
||||
let layout = defaults
|
||||
for (let page = 1; page < MAX_HOME_GRID_PAGES; page += 1) {
|
||||
layout = addHomePage(layout)
|
||||
}
|
||||
|
||||
expect(layout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES)
|
||||
expect(addHomePage(layout)).toBe(layout)
|
||||
})
|
||||
|
||||
it('deletes a page and moves its apps into remaining empty slots', () => {
|
||||
let layout = addHomePage(defaults)
|
||||
layout = moveHomeApp(layout, 'grid', 0, 'grid', HOME_GRID_PAGE_SIZE)
|
||||
const deleted = deleteHomePage(layout, 2)
|
||||
|
||||
expect(deleted.grid).toHaveLength(HOME_GRID_PAGE_SIZE)
|
||||
expect(deleted.grid).toContain('phone')
|
||||
expect(deleteHomePage(deleted, 1)).toBe(deleted)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
|
||||
export const HOME_DOCK_CAPACITY = 4
|
||||
export const HOME_GRID_PAGE_SIZE = 20
|
||||
const MAX_HOME_GRID_PAGES = 5
|
||||
export const MAX_HOME_GRID_PAGES = 5
|
||||
|
||||
export type HomeArea = 'dock' | 'grid'
|
||||
export type HomeSlot = LaunchablePhoneAppId | null
|
||||
@@ -221,6 +221,41 @@ export function restoreHomeApp(
|
||||
}
|
||||
}
|
||||
|
||||
export function addHomePage(layout: HomeLayout): HomeLayout {
|
||||
if (layout.grid.length >= HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES) {
|
||||
return layout
|
||||
}
|
||||
|
||||
return {
|
||||
dock: [...layout.dock],
|
||||
grid: [...layout.grid, ...createSlots(HOME_GRID_PAGE_SIZE)],
|
||||
hidden: [...layout.hidden],
|
||||
version: 2,
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteHomePage(layout: HomeLayout, page: number): HomeLayout {
|
||||
const pageCount = Math.ceil(layout.grid.length / HOME_GRID_PAGE_SIZE)
|
||||
if (pageCount <= 1 || page < 1 || page > pageCount) return layout
|
||||
|
||||
const pageStart = (page - 1) * HOME_GRID_PAGE_SIZE
|
||||
const grid = [...layout.grid]
|
||||
const removedApps = grid
|
||||
.splice(pageStart, HOME_GRID_PAGE_SIZE)
|
||||
.filter((appId): appId is LaunchablePhoneAppId => appId !== null)
|
||||
if (removedApps.length > grid.filter((appId) => appId === null).length) {
|
||||
return layout
|
||||
}
|
||||
for (const appId of removedApps) placeInFirstEmptySlot(grid, appId)
|
||||
|
||||
return {
|
||||
dock: [...layout.dock],
|
||||
grid,
|
||||
hidden: [...layout.hidden],
|
||||
version: 2,
|
||||
}
|
||||
}
|
||||
|
||||
export function moveHomeApp(
|
||||
layout: HomeLayout,
|
||||
from: HomeArea,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
addWidget,
|
||||
createDefaultWidgetLayout,
|
||||
deleteWidgetPage,
|
||||
moveWidget,
|
||||
parseWidgetLayout,
|
||||
removeWidget,
|
||||
resizeWidget,
|
||||
widgetOccupiedCells,
|
||||
} from '@/utils/widgetLayout'
|
||||
|
||||
describe('widget layout', () => {
|
||||
it('creates a non-overlapping default layout across both surfaces', () => {
|
||||
const layout = createDefaultWidgetLayout()
|
||||
|
||||
expect(layout.instances).toHaveLength(7)
|
||||
expect(widgetOccupiedCells(layout.instances, 0).size).toBe(32)
|
||||
expect(widgetOccupiedCells(layout.instances, 1).size).toBe(16)
|
||||
})
|
||||
|
||||
it('adds a widget to the first free snapped position', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(42)
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
||||
const layout = createDefaultWidgetLayout()
|
||||
const next = addWidget(layout, 'wallet', 'small', 1)
|
||||
const added = next.instances.find((instance) =>
|
||||
instance.id.startsWith('wallet-'),
|
||||
)
|
||||
|
||||
expect(added).toMatchObject({ column: 0, page: 2, row: 0, size: 'small' })
|
||||
})
|
||||
|
||||
it('moves a widget and reflows a displaced neighbor', () => {
|
||||
const layout = createDefaultWidgetLayout()
|
||||
const next = moveWidget(layout, 'home-clock', 1, 2, 0)
|
||||
const clock = next.instances.find(
|
||||
(instance) => instance.id === 'home-clock',
|
||||
)
|
||||
const weather = next.instances.find(
|
||||
(instance) => instance.id === 'home-weather',
|
||||
)
|
||||
|
||||
expect(clock).toMatchObject({ column: 2, page: 1, row: 0 })
|
||||
expect(weather).toMatchObject({ column: 0, page: 1, row: 0 })
|
||||
expect(next.instances).toHaveLength(layout.instances.length)
|
||||
})
|
||||
|
||||
it('allows a small widget in the center with app cells on both sides', () => {
|
||||
const layout = createDefaultWidgetLayout()
|
||||
const moved = moveWidget(layout, 'home-clock', 2, 1, 1)
|
||||
|
||||
expect(
|
||||
moved.instances.find((instance) => instance.id === 'home-clock'),
|
||||
).toMatchObject({ column: 1, page: 2, row: 1 })
|
||||
expect([...widgetOccupiedCells(moved.instances, 2)]).toEqual([5, 6, 9, 10])
|
||||
expect(widgetOccupiedCells(moved.instances, 2).has(4)).toBe(false)
|
||||
expect(widgetOccupiedCells(moved.instances, 2).has(7)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the original layout when displaced widgets cannot be reflowed', () => {
|
||||
const layout = createDefaultWidgetLayout()
|
||||
const packed = {
|
||||
instances: [
|
||||
layout.instances.find((instance) => instance.id === 'home-clock')!,
|
||||
...Array.from({ length: 5 }, (_, index) => ({
|
||||
column: 0,
|
||||
id: `packed-${index}`,
|
||||
kind: 'date' as const,
|
||||
page: 0,
|
||||
row: index * 2,
|
||||
settings: {},
|
||||
size: 'medium' as const,
|
||||
})),
|
||||
],
|
||||
version: 1 as const,
|
||||
}
|
||||
|
||||
expect(moveWidget(packed, 'home-clock', 0, 0, 0)).toBe(packed)
|
||||
})
|
||||
|
||||
it('resizes and removes widgets without changing unrelated settings', () => {
|
||||
const layout = createDefaultWidgetLayout()
|
||||
const resized = resizeWidget(layout, 'home-clock', 'medium')
|
||||
const removed = removeWidget(resized, 'home-weather')
|
||||
|
||||
expect(
|
||||
resized.instances.find((instance) => instance.id === 'home-clock'),
|
||||
).toMatchObject({ settings: { showDate: true }, size: 'medium' })
|
||||
expect(
|
||||
removed.instances.some((instance) => instance.id === 'home-weather'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unsupported and duplicate persisted entries', () => {
|
||||
const parsed = parseWidgetLayout({
|
||||
instances: [
|
||||
{
|
||||
column: 0,
|
||||
id: 'clock-a',
|
||||
kind: 'clock',
|
||||
page: 1,
|
||||
row: 0,
|
||||
settings: { showDate: true },
|
||||
size: 'large',
|
||||
},
|
||||
{
|
||||
column: 2,
|
||||
id: 'clock-a',
|
||||
kind: 'clock',
|
||||
page: 1,
|
||||
row: 0,
|
||||
settings: {},
|
||||
size: 'small',
|
||||
},
|
||||
],
|
||||
version: 1,
|
||||
})
|
||||
|
||||
expect(parsed.instances).toHaveLength(1)
|
||||
expect(parsed.instances[0]?.size).toBe('small')
|
||||
})
|
||||
|
||||
it('deletes a home page and preserves its widgets on remaining pages', () => {
|
||||
const layout = createDefaultWidgetLayout()
|
||||
const moved = moveWidget(layout, 'home-clock', 2, 1, 1)
|
||||
const deleted = deleteWidgetPage(moved, 2, 1)
|
||||
|
||||
expect(deleted).not.toBe(moved)
|
||||
expect(
|
||||
deleted.instances.find((instance) => instance.id === 'home-clock')?.page,
|
||||
).toBe(1)
|
||||
expect(deleted.instances).toHaveLength(moved.instances.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,357 @@
|
||||
import { WIDGET_REGISTRY_BY_KIND } from '@/config/widgets'
|
||||
import type {
|
||||
WidgetInstance,
|
||||
WidgetKind,
|
||||
WidgetLayout,
|
||||
WidgetSettings,
|
||||
WidgetSize,
|
||||
} from '@/types/widgets'
|
||||
|
||||
export const WIDGET_GRID_COLUMNS = 4
|
||||
export const WIDGET_HOME_ROWS = 5
|
||||
export const WIDGET_PAGE_ROWS = 10
|
||||
const MAX_WIDGET_HOME_PAGES = 5
|
||||
|
||||
export const WIDGET_SPANS: Record<
|
||||
WidgetSize,
|
||||
{ columns: number; rows: number }
|
||||
> = {
|
||||
small: { columns: 2, rows: 2 },
|
||||
medium: { columns: 4, rows: 2 },
|
||||
large: { columns: 4, rows: 4 },
|
||||
}
|
||||
|
||||
function rowsForPage(page: number): number {
|
||||
return page === 0 ? WIDGET_PAGE_ROWS : WIDGET_HOME_ROWS
|
||||
}
|
||||
|
||||
function overlaps(left: WidgetInstance, right: WidgetInstance): boolean {
|
||||
if (left.page !== right.page) return false
|
||||
const leftSpan = WIDGET_SPANS[left.size]
|
||||
const rightSpan = WIDGET_SPANS[right.size]
|
||||
return (
|
||||
left.column < right.column + rightSpan.columns &&
|
||||
left.column + leftSpan.columns > right.column &&
|
||||
left.row < right.row + rightSpan.rows &&
|
||||
left.row + leftSpan.rows > right.row
|
||||
)
|
||||
}
|
||||
|
||||
function fits(instance: WidgetInstance, placed: WidgetInstance[]): boolean {
|
||||
const span = WIDGET_SPANS[instance.size]
|
||||
return (
|
||||
instance.column >= 0 &&
|
||||
instance.row >= 0 &&
|
||||
instance.column + span.columns <= WIDGET_GRID_COLUMNS &&
|
||||
instance.row + span.rows <= rowsForPage(instance.page) &&
|
||||
!placed.some((candidate) => overlaps(instance, candidate))
|
||||
)
|
||||
}
|
||||
|
||||
function findPosition(
|
||||
instance: WidgetInstance,
|
||||
placed: WidgetInstance[],
|
||||
startPage: number,
|
||||
maximumPage = MAX_WIDGET_HOME_PAGES,
|
||||
): WidgetInstance | null {
|
||||
const lastPage = startPage === 0 ? 0 : maximumPage
|
||||
for (let page = startPage; page <= lastPage; page += 1) {
|
||||
const span = WIDGET_SPANS[instance.size]
|
||||
for (let row = 0; row <= rowsForPage(page) - span.rows; row += 1) {
|
||||
for (
|
||||
let column = 0;
|
||||
column <= WIDGET_GRID_COLUMNS - span.columns;
|
||||
column += 1
|
||||
) {
|
||||
const candidate = { ...instance, column, page, row }
|
||||
if (fits(candidate, placed)) return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function deleteWidgetPage(
|
||||
layout: WidgetLayout,
|
||||
page: number,
|
||||
maximumPage: number,
|
||||
): WidgetLayout {
|
||||
if (page < 1 || maximumPage < 1 || page > maximumPage + 1) return layout
|
||||
|
||||
const placed = layout.instances
|
||||
.filter((instance) => instance.page !== page)
|
||||
.map((instance) => ({
|
||||
...instance,
|
||||
page: instance.page > page ? instance.page - 1 : instance.page,
|
||||
}))
|
||||
const removed = layout.instances.filter((instance) => instance.page === page)
|
||||
for (const instance of removed) {
|
||||
const positioned = findPosition(
|
||||
{ ...instance, page: 1 },
|
||||
placed,
|
||||
1,
|
||||
maximumPage,
|
||||
)
|
||||
if (!positioned) return layout
|
||||
placed.push(positioned)
|
||||
}
|
||||
|
||||
return { instances: placed, version: 1 }
|
||||
}
|
||||
|
||||
function normalizeSettings(value: unknown): WidgetSettings {
|
||||
if (!value || typeof value !== 'object') return {}
|
||||
const source = value as WidgetSettings
|
||||
return {
|
||||
...(source.balanceSource === 'cash' || source.balanceSource === 'bank'
|
||||
? { balanceSource: source.balanceSource }
|
||||
: {}),
|
||||
...(Array.isArray(source.contactIds)
|
||||
? {
|
||||
contactIds: source.contactIds
|
||||
.filter((id): id is string => typeof id === 'string')
|
||||
.slice(0, 6),
|
||||
}
|
||||
: {}),
|
||||
...(typeof source.showDate === 'boolean'
|
||||
? { showDate: source.showDate }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultWidgetLayout(): WidgetLayout {
|
||||
return {
|
||||
instances: [
|
||||
{
|
||||
column: 0,
|
||||
id: 'home-clock',
|
||||
kind: 'clock',
|
||||
page: 1,
|
||||
row: 0,
|
||||
settings: { showDate: true },
|
||||
size: 'small',
|
||||
},
|
||||
{
|
||||
column: 2,
|
||||
id: 'home-weather',
|
||||
kind: 'weather',
|
||||
page: 1,
|
||||
row: 0,
|
||||
settings: {},
|
||||
size: 'small',
|
||||
},
|
||||
{
|
||||
column: 0,
|
||||
id: 'home-music',
|
||||
kind: 'music',
|
||||
page: 1,
|
||||
row: 2,
|
||||
settings: {},
|
||||
size: 'medium',
|
||||
},
|
||||
{
|
||||
column: 0,
|
||||
id: 'page-date',
|
||||
kind: 'date',
|
||||
page: 0,
|
||||
row: 0,
|
||||
settings: {},
|
||||
size: 'medium',
|
||||
},
|
||||
{
|
||||
column: 0,
|
||||
id: 'page-wallet',
|
||||
kind: 'wallet',
|
||||
page: 0,
|
||||
row: 2,
|
||||
settings: { balanceSource: 'bank' },
|
||||
size: 'medium',
|
||||
},
|
||||
{
|
||||
column: 0,
|
||||
id: 'page-transactions',
|
||||
kind: 'transactions',
|
||||
page: 0,
|
||||
row: 4,
|
||||
settings: {},
|
||||
size: 'medium',
|
||||
},
|
||||
{
|
||||
column: 0,
|
||||
id: 'page-contacts',
|
||||
kind: 'contacts',
|
||||
page: 0,
|
||||
row: 6,
|
||||
settings: {},
|
||||
size: 'medium',
|
||||
},
|
||||
],
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseWidgetLayout(value: unknown): WidgetLayout {
|
||||
if (!value || typeof value !== 'object') return createDefaultWidgetLayout()
|
||||
const source = value as Partial<WidgetLayout>
|
||||
if (source.version !== 1 || !Array.isArray(source.instances))
|
||||
return createDefaultWidgetLayout()
|
||||
|
||||
const placed: WidgetInstance[] = []
|
||||
const ids = new Set<string>()
|
||||
for (const raw of source.instances) {
|
||||
if (!raw || typeof raw !== 'object') continue
|
||||
const candidate = raw as Partial<WidgetInstance>
|
||||
if (
|
||||
typeof candidate.id !== 'string' ||
|
||||
ids.has(candidate.id) ||
|
||||
typeof candidate.kind !== 'string' ||
|
||||
!WIDGET_REGISTRY_BY_KIND.has(candidate.kind as WidgetKind)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const definition = WIDGET_REGISTRY_BY_KIND.get(candidate.kind as WidgetKind)
|
||||
const size = definition?.supportedSizes.includes(
|
||||
candidate.size as WidgetSize,
|
||||
)
|
||||
? (candidate.size as WidgetSize)
|
||||
: definition?.defaultSize
|
||||
if (!size) continue
|
||||
const instance: WidgetInstance = {
|
||||
column: Math.floor(Number(candidate.column) || 0),
|
||||
id: candidate.id,
|
||||
kind: candidate.kind as WidgetKind,
|
||||
page: Math.max(0, Math.floor(Number(candidate.page) || 0)),
|
||||
row: Math.floor(Number(candidate.row) || 0),
|
||||
settings: normalizeSettings(candidate.settings),
|
||||
size,
|
||||
}
|
||||
const normalized = fits(instance, placed)
|
||||
? instance
|
||||
: findPosition(instance, placed, instance.page)
|
||||
if (!normalized) continue
|
||||
placed.push(normalized)
|
||||
ids.add(normalized.id)
|
||||
}
|
||||
return { instances: placed, version: 1 }
|
||||
}
|
||||
|
||||
export function addWidget(
|
||||
layout: WidgetLayout,
|
||||
kind: WidgetKind,
|
||||
size: WidgetSize,
|
||||
page: number,
|
||||
): WidgetLayout {
|
||||
const definition = WIDGET_REGISTRY_BY_KIND.get(kind)
|
||||
if (!definition?.supportedSizes.includes(size)) return layout
|
||||
const instance: WidgetInstance = {
|
||||
column: 0,
|
||||
id: `${kind}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
kind,
|
||||
page,
|
||||
row: 0,
|
||||
settings: {
|
||||
...(kind === 'clock' ? { showDate: true } : {}),
|
||||
...(kind === 'wallet' ? { balanceSource: 'bank' as const } : {}),
|
||||
},
|
||||
size,
|
||||
}
|
||||
const positioned = findPosition(instance, layout.instances, page)
|
||||
if (!positioned) return layout
|
||||
return { instances: [...layout.instances, positioned], version: 1 }
|
||||
}
|
||||
|
||||
export function removeWidget(layout: WidgetLayout, id: string): WidgetLayout {
|
||||
return {
|
||||
instances: layout.instances.filter((instance) => instance.id !== id),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function moveWidget(
|
||||
layout: WidgetLayout,
|
||||
id: string,
|
||||
page: number,
|
||||
column: number,
|
||||
row: number,
|
||||
): WidgetLayout {
|
||||
const moving = layout.instances.find((instance) => instance.id === id)
|
||||
if (!moving) return layout
|
||||
const span = WIDGET_SPANS[moving.size]
|
||||
const requested: WidgetInstance = {
|
||||
...moving,
|
||||
column: Math.max(0, Math.min(column, WIDGET_GRID_COLUMNS - span.columns)),
|
||||
page,
|
||||
row: Math.max(0, Math.min(row, rowsForPage(page) - span.rows)),
|
||||
}
|
||||
const placed: WidgetInstance[] = [requested]
|
||||
for (const instance of layout.instances) {
|
||||
if (instance.id === id) continue
|
||||
if (fits(instance, placed)) placed.push(instance)
|
||||
else {
|
||||
const repositioned = findPosition(instance, placed, instance.page)
|
||||
if (!repositioned) return layout
|
||||
placed.push(repositioned)
|
||||
}
|
||||
}
|
||||
return { instances: placed, version: 1 }
|
||||
}
|
||||
|
||||
export function resizeWidget(
|
||||
layout: WidgetLayout,
|
||||
id: string,
|
||||
size: WidgetSize,
|
||||
): WidgetLayout {
|
||||
const instance = layout.instances.find((candidate) => candidate.id === id)
|
||||
const definition = instance && WIDGET_REGISTRY_BY_KIND.get(instance.kind)
|
||||
if (!instance || !definition?.supportedSizes.includes(size)) return layout
|
||||
const resized = { ...instance, size }
|
||||
const remaining = layout.instances.filter((candidate) => candidate.id !== id)
|
||||
const positioned = findPosition(resized, remaining, instance.page)
|
||||
if (!positioned) return layout
|
||||
return {
|
||||
instances: [
|
||||
...remaining.map((candidate) => ({ ...candidate })),
|
||||
positioned,
|
||||
],
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function updateWidgetSettings(
|
||||
layout: WidgetLayout,
|
||||
id: string,
|
||||
settings: WidgetSettings,
|
||||
): WidgetLayout {
|
||||
return {
|
||||
instances: layout.instances.map((instance) =>
|
||||
instance.id === id
|
||||
? {
|
||||
...instance,
|
||||
settings: normalizeSettings({ ...instance.settings, ...settings }),
|
||||
}
|
||||
: instance,
|
||||
),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function widgetOccupiedCells(
|
||||
instances: WidgetInstance[],
|
||||
page: number,
|
||||
): Set<number> {
|
||||
const cells = new Set<number>()
|
||||
for (const instance of instances) {
|
||||
if (instance.page !== page) continue
|
||||
const span = WIDGET_SPANS[instance.size]
|
||||
for (let row = instance.row; row < instance.row + span.rows; row += 1) {
|
||||
for (
|
||||
let column = instance.column;
|
||||
column < instance.column + span.columns;
|
||||
column += 1
|
||||
) {
|
||||
cells.add(row * WIDGET_GRID_COLUMNS + column)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { Search, X } from 'lucide-vue-next'
|
||||
import { kGlass } from 'konsta/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Pencil, Plus, Search, Trash2, X } from 'lucide-vue-next'
|
||||
import { kButton, kGlass, kList, kListItem, kSheet } from 'konsta/vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import SpringboardWidgets from '@/components/SpringboardWidgets.vue'
|
||||
import SpringboardWidgetGrid from '@/components/SpringboardWidgetGrid.vue'
|
||||
import WidgetConfigSheet from '@/components/WidgetConfigSheet.vue'
|
||||
import WidgetPickerSheet from '@/components/WidgetPickerSheet.vue'
|
||||
import { PHONE_APPS } from '@/config/apps'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { useWidgetsStore } from '@/stores/widgets'
|
||||
import type { PhoneAppCategory, PhoneAppDefinition } from '@/types/apps'
|
||||
import type { LaunchablePhoneAppId } from '@/types/apps'
|
||||
import { HOME_GRID_PAGE_SIZE, type HomeArea } from '@/utils/homeLayout'
|
||||
import { paginateItems } from '@/utils/pages'
|
||||
import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets'
|
||||
import {
|
||||
deleteHomePage as previewHomePageDelete,
|
||||
HOME_GRID_PAGE_SIZE,
|
||||
MAX_HOME_GRID_PAGES,
|
||||
type HomeArea,
|
||||
} from '@/utils/homeLayout'
|
||||
import {
|
||||
deleteWidgetPage as previewWidgetPageDelete,
|
||||
moveWidget as previewWidgetMove,
|
||||
WIDGET_GRID_COLUMNS,
|
||||
widgetOccupiedCells,
|
||||
} from '@/utils/widgetLayout'
|
||||
|
||||
const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
|
||||
'games',
|
||||
@@ -22,18 +36,42 @@ const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
|
||||
]
|
||||
const phone = usePhoneStore()
|
||||
const appStore = useAppStoreStore()
|
||||
const widgets = useWidgetsStore()
|
||||
const searchQuery = ref('')
|
||||
const searchFocused = ref(false)
|
||||
const showAllApps = ref(false)
|
||||
const editMode = ref(false)
|
||||
const widgetPickerOpened = ref(false)
|
||||
const widgetActionId = ref<string | null>(null)
|
||||
const widgetConfigId = ref<string | null>(null)
|
||||
const draggingWidgetId = ref<string | null>(null)
|
||||
const widgetDragGrip = ref<{ x: number; y: number } | null>(null)
|
||||
const widgetDragSize = ref<{ height: number; width: number } | null>(null)
|
||||
const widgetDragPreview = ref<{
|
||||
column: number
|
||||
id: string
|
||||
page: number
|
||||
row: number
|
||||
} | null>(null)
|
||||
const dragOffset = ref(0)
|
||||
const dragging = ref(false)
|
||||
const pageTransitioning = ref(false)
|
||||
const draggingHomeApp = ref<{
|
||||
area: HomeArea
|
||||
index: number
|
||||
} | null>(null)
|
||||
let pointerStart = 0
|
||||
let pointerStartY = 0
|
||||
let pointerStartedAt = 0
|
||||
let pointerPageStart = 0
|
||||
let backgroundHoldTimer: number | undefined
|
||||
let blankTapCandidate = false
|
||||
let widgetPreviewTimer: number | undefined
|
||||
let pendingWidgetPointer: { clientX: number; clientY: number } | null = null
|
||||
let lastWidgetPointer: { clientX: number; clientY: number } | null = null
|
||||
let edgePageTimer: number | undefined
|
||||
let edgePageDirection = 0
|
||||
let edgePageLocked = false
|
||||
|
||||
const installedApps = computed(() =>
|
||||
PHONE_APPS.filter(
|
||||
@@ -43,19 +81,106 @@ const installedApps = computed(() =>
|
||||
const installedAppsById = computed(
|
||||
() => new Map(installedApps.value.map((app) => [app.id, app])),
|
||||
)
|
||||
const gridSlots = computed(() =>
|
||||
appStore.homeLayout.grid.map((id) =>
|
||||
id ? (installedAppsById.value.get(id) ?? null) : null,
|
||||
const hiddenApps = computed(() =>
|
||||
installedApps.value.filter((app) =>
|
||||
appStore.homeLayout.hidden.includes(app.id),
|
||||
),
|
||||
)
|
||||
const appPages = computed(() =>
|
||||
paginateItems(gridSlots.value, HOME_GRID_PAGE_SIZE),
|
||||
const gridEntries = computed(() =>
|
||||
appStore.homeLayout.grid
|
||||
.map((id, sourceIndex) => ({
|
||||
app: id ? (installedAppsById.value.get(id) ?? null) : null,
|
||||
sourceIndex,
|
||||
}))
|
||||
.filter(
|
||||
(entry): entry is { app: PhoneAppDefinition; sourceIndex: number } =>
|
||||
entry.app !== null,
|
||||
),
|
||||
)
|
||||
const previewWidgetLayout = computed(() => {
|
||||
const preview = widgetDragPreview.value
|
||||
return preview
|
||||
? previewWidgetMove(
|
||||
widgets.layout,
|
||||
preview.id,
|
||||
preview.page,
|
||||
preview.column,
|
||||
preview.row,
|
||||
)
|
||||
: widgets.layout
|
||||
})
|
||||
const appPages = computed(() => {
|
||||
const entries = [...gridEntries.value]
|
||||
const pages: Array<{
|
||||
cells: Array<{ app: PhoneAppDefinition; sourceIndex: number } | null>
|
||||
page: number
|
||||
}> = []
|
||||
const maxWidgetPage = Math.max(
|
||||
1,
|
||||
...previewWidgetLayout.value.instances.map((instance) => instance.page),
|
||||
)
|
||||
const persistedHomePages = Math.max(
|
||||
1,
|
||||
Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE),
|
||||
)
|
||||
const lastHomePage = Math.max(maxWidgetPage, persistedHomePages)
|
||||
let page = 1
|
||||
let entryIndex = 0
|
||||
while (entryIndex < entries.length || page <= lastHomePage) {
|
||||
const occupied = widgetOccupiedCells(
|
||||
previewWidgetLayout.value.instances,
|
||||
page,
|
||||
)
|
||||
if (widgetDragPreview.value) {
|
||||
for (const cell of widgetOccupiedCells(widgets.layout.instances, page)) {
|
||||
occupied.add(cell)
|
||||
}
|
||||
}
|
||||
const cells = Array.from<{
|
||||
app: PhoneAppDefinition
|
||||
sourceIndex: number
|
||||
} | null>({ length: HOME_GRID_PAGE_SIZE }).fill(null)
|
||||
for (let cell = 0; cell < HOME_GRID_PAGE_SIZE; cell += 1) {
|
||||
if (occupied.has(cell) || entryIndex >= entries.length) continue
|
||||
cells[cell] = entries[entryIndex]
|
||||
entryIndex += 1
|
||||
}
|
||||
pages.push({ cells, page })
|
||||
page += 1
|
||||
}
|
||||
return pages.length
|
||||
? pages
|
||||
: [{ cells: Array(HOME_GRID_PAGE_SIZE).fill(null), page: 1 }]
|
||||
})
|
||||
const pageCount = computed(() => appPages.value.length + 2)
|
||||
const libraryPage = computed(() => pageCount.value - 1)
|
||||
const isAppPage = computed(
|
||||
() => phone.currentPage > 0 && phone.currentPage < libraryPage.value,
|
||||
)
|
||||
const isEditablePage = computed(
|
||||
() => phone.currentPage >= 0 && phone.currentPage < libraryPage.value,
|
||||
)
|
||||
const canAddHomePage = computed(
|
||||
() =>
|
||||
appStore.homeLayout.grid.length < HOME_GRID_PAGE_SIZE * MAX_HOME_GRID_PAGES,
|
||||
)
|
||||
const addingHomePage = ref(false)
|
||||
const persistedHomePageCount = computed(() =>
|
||||
Math.max(1, Math.ceil(appStore.homeLayout.grid.length / HOME_GRID_PAGE_SIZE)),
|
||||
)
|
||||
const canDeleteCurrentPage = computed(() => {
|
||||
if (phone.currentPage < 1 || persistedHomePageCount.value <= 1) return false
|
||||
const remainingPages = persistedHomePageCount.value - 1
|
||||
return (
|
||||
previewHomePageDelete(appStore.homeLayout, phone.currentPage) !==
|
||||
appStore.homeLayout &&
|
||||
previewWidgetPageDelete(
|
||||
widgets.layout,
|
||||
phone.currentPage,
|
||||
remainingPages,
|
||||
) !== widgets.layout
|
||||
)
|
||||
})
|
||||
const dockSlots = computed(() =>
|
||||
appStore.homeLayout.dock.map((id) =>
|
||||
id ? (installedAppsById.value.get(id) ?? null) : null,
|
||||
@@ -113,53 +238,348 @@ const trackStyle = computed(() => ({
|
||||
width: `${pageCount.value * 100}%`,
|
||||
}))
|
||||
const pageStyle = computed(() => ({ width: `${100 / pageCount.value}%` }))
|
||||
const activeWidget = computed(() =>
|
||||
widgets.layout.instances.find(
|
||||
(instance) => instance.id === widgetActionId.value,
|
||||
),
|
||||
)
|
||||
const configuredWidget = computed(
|
||||
() =>
|
||||
widgets.layout.instances.find(
|
||||
(instance) => instance.id === widgetConfigId.value,
|
||||
) ?? null,
|
||||
)
|
||||
|
||||
function clearBackgroundHold(): void {
|
||||
if (backgroundHoldTimer !== undefined)
|
||||
window.clearTimeout(backgroundHoldTimer)
|
||||
backgroundHoldTimer = undefined
|
||||
}
|
||||
|
||||
function changePage(page: number): void {
|
||||
const targetPage = Math.max(0, Math.min(page, pageCount.value - 1))
|
||||
if (targetPage === phone.currentPage) {
|
||||
pageTransitioning.value = false
|
||||
return
|
||||
}
|
||||
pageTransitioning.value = true
|
||||
phone.setCurrentPage(targetPage, pageCount.value)
|
||||
}
|
||||
|
||||
function finishPageTransition(event: TransitionEvent): void {
|
||||
if (event.propertyName !== 'transform') return
|
||||
pageTransitioning.value = false
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
if (editMode.value) return
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('button, input')) return
|
||||
if (target.closest('button, input, .home-widget-shell')) {
|
||||
clearBackgroundHold()
|
||||
dragging.value = false
|
||||
dragOffset.value = 0
|
||||
blankTapCandidate = false
|
||||
return
|
||||
}
|
||||
pointerStart = event.clientX
|
||||
pointerStartY = event.clientY
|
||||
pointerStartedAt = Date.now()
|
||||
pointerPageStart = phone.currentPage
|
||||
blankTapCandidate = editMode.value
|
||||
if (editMode.value) return
|
||||
dragging.value = true
|
||||
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||
clearBackgroundHold()
|
||||
backgroundHoldTimer = window.setTimeout(() => {
|
||||
enterEditMode()
|
||||
backgroundHoldTimer = undefined
|
||||
}, 520)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
const distanceX = event.clientX - pointerStart
|
||||
const distanceY = event.clientY - pointerStartY
|
||||
if (Math.hypot(distanceX, distanceY) > 8) {
|
||||
clearBackgroundHold()
|
||||
blankTapCandidate = false
|
||||
}
|
||||
if (!dragging.value) return
|
||||
dragOffset.value = event.clientX - pointerStart
|
||||
if (Math.abs(distanceY) > Math.abs(distanceX)) {
|
||||
dragging.value = false
|
||||
dragOffset.value = 0
|
||||
return
|
||||
}
|
||||
const atFirstPage = pointerPageStart === 0 && distanceX > 0
|
||||
const atLastPage = pointerPageStart === pageCount.value - 1 && distanceX < 0
|
||||
dragOffset.value = atFirstPage || atLastPage ? distanceX * 0.28 : distanceX
|
||||
}
|
||||
|
||||
function finishPointer(event: PointerEvent): void {
|
||||
clearBackgroundHold()
|
||||
if (editMode.value && blankTapCandidate) {
|
||||
blankTapCandidate = false
|
||||
editMode.value = false
|
||||
return
|
||||
}
|
||||
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),
|
||||
pageCount.value,
|
||||
)
|
||||
changePage(pointerPageStart + (distance < 0 ? 1 : -1))
|
||||
}
|
||||
dragging.value = false
|
||||
dragOffset.value = 0
|
||||
}
|
||||
|
||||
function cancelPointer(): void {
|
||||
clearBackgroundHold()
|
||||
blankTapCandidate = false
|
||||
dragging.value = false
|
||||
dragOffset.value = 0
|
||||
}
|
||||
|
||||
function enterEditMode(): void {
|
||||
editMode.value = true
|
||||
dragging.value = false
|
||||
dragOffset.value = 0
|
||||
}
|
||||
|
||||
function openWidgetMenu(id: string): void {
|
||||
enterEditMode()
|
||||
widgetActionId.value = id
|
||||
}
|
||||
|
||||
function startWidgetDrag(id: string, event: PointerEvent): void {
|
||||
const widget = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-widget-id]'),
|
||||
).find((candidate) => candidate.dataset.widgetId === id)
|
||||
if (!widget) return
|
||||
pageTransitioning.value = false
|
||||
const bounds = widget.getBoundingClientRect()
|
||||
draggingWidgetId.value = id
|
||||
widgetDragGrip.value = {
|
||||
x: event.clientX - bounds.left,
|
||||
y: event.clientY - bounds.top,
|
||||
}
|
||||
widgetDragSize.value = { height: bounds.height, width: bounds.width }
|
||||
}
|
||||
|
||||
function updateWidgetDragPreview(event: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
}): void {
|
||||
const id = draggingWidgetId.value
|
||||
const grip = widgetDragGrip.value
|
||||
if (!id || !grip) return
|
||||
const page = phone.currentPage
|
||||
const grid = document.querySelector<HTMLElement>(
|
||||
`[data-widget-page="${page}"]`,
|
||||
)
|
||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||
const springboard = grid?.closest<HTMLElement>('.springboard')
|
||||
if (!grid || !pageElement || !springboard) return
|
||||
const renderedGridBounds = grid.getBoundingClientRect()
|
||||
const pageBounds = pageElement.getBoundingClientRect()
|
||||
const springboardBounds = springboard.getBoundingClientRect()
|
||||
const gridLeft =
|
||||
springboardBounds.left + (renderedGridBounds.left - pageBounds.left)
|
||||
const gridStyle = getComputedStyle(grid)
|
||||
const columnGap = Number.parseFloat(gridStyle.columnGap) || 0
|
||||
const rowGap = Number.parseFloat(gridStyle.rowGap) || 0
|
||||
const columnWidth =
|
||||
(renderedGridBounds.width - columnGap * (WIDGET_GRID_COLUMNS - 1)) /
|
||||
WIDGET_GRID_COLUMNS
|
||||
const rowHeight = Number.parseFloat(gridStyle.gridAutoRows)
|
||||
const column = Math.round(
|
||||
(event.clientX - grip.x - gridLeft) / (columnWidth + columnGap),
|
||||
)
|
||||
const row = Math.round(
|
||||
(event.clientY - grip.y - renderedGridBounds.top) / (rowHeight + rowGap),
|
||||
)
|
||||
if (Number.isFinite(rowHeight)) {
|
||||
const current = widgetDragPreview.value
|
||||
if (
|
||||
!current ||
|
||||
current.id !== id ||
|
||||
current.page !== page ||
|
||||
current.column !== column ||
|
||||
current.row !== row
|
||||
) {
|
||||
widgetDragPreview.value = { column, id, page, row }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearEdgePageTurn(): void {
|
||||
if (edgePageTimer !== undefined) window.clearTimeout(edgePageTimer)
|
||||
edgePageTimer = undefined
|
||||
edgePageDirection = 0
|
||||
edgePageLocked = false
|
||||
}
|
||||
|
||||
function queueEdgePageTurn(
|
||||
event: PointerEvent,
|
||||
dragType: 'app' | 'widget',
|
||||
): void {
|
||||
const springboard = document.querySelector<HTMLElement>('.springboard')
|
||||
if (!springboard) return
|
||||
const bounds = springboard.getBoundingClientRect()
|
||||
const edgeSize = Math.min(42, bounds.width * 0.12)
|
||||
let direction = 0
|
||||
if (dragType === 'widget' && widgetDragGrip.value && widgetDragSize.value) {
|
||||
const widgetLeft = event.clientX - widgetDragGrip.value.x
|
||||
const widgetRight = widgetLeft + widgetDragSize.value.width
|
||||
const overhang = Math.min(22, widgetDragSize.value.width * 0.14)
|
||||
if (widgetLeft <= bounds.left - overhang) direction = -1
|
||||
else if (widgetRight >= bounds.right + overhang) direction = 1
|
||||
} else if (event.clientX <= bounds.left + edgeSize) direction = -1
|
||||
else if (event.clientX >= bounds.right - edgeSize) direction = 1
|
||||
const minimumPage = dragType === 'widget' ? 0 : 1
|
||||
const maximumPage = libraryPage.value - 1
|
||||
const destination = phone.currentPage + direction
|
||||
|
||||
if (
|
||||
direction === 0 ||
|
||||
destination < minimumPage ||
|
||||
destination > maximumPage
|
||||
) {
|
||||
if (edgePageTimer !== undefined) window.clearTimeout(edgePageTimer)
|
||||
edgePageTimer = undefined
|
||||
edgePageDirection = 0
|
||||
edgePageLocked = false
|
||||
return
|
||||
}
|
||||
if (edgePageLocked && edgePageDirection === direction) return
|
||||
if (edgePageLocked) edgePageLocked = false
|
||||
if (edgePageTimer !== undefined && edgePageDirection === direction) return
|
||||
if (edgePageTimer !== undefined) window.clearTimeout(edgePageTimer)
|
||||
edgePageDirection = direction
|
||||
edgePageTimer = window.setTimeout(() => {
|
||||
const targetPage = phone.currentPage + direction
|
||||
changePage(targetPage)
|
||||
if (dragType === 'widget' && lastWidgetPointer) {
|
||||
updateWidgetDragPreview(lastWidgetPointer)
|
||||
}
|
||||
edgePageTimer = undefined
|
||||
edgePageDirection = direction
|
||||
edgePageLocked = true
|
||||
}, 520)
|
||||
}
|
||||
|
||||
function queueWidgetDragPreview(event: PointerEvent): void {
|
||||
queueEdgePageTurn(event, 'widget')
|
||||
lastWidgetPointer = {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
}
|
||||
pendingWidgetPointer = lastWidgetPointer
|
||||
if (widgetPreviewTimer !== undefined) return
|
||||
widgetPreviewTimer = window.setTimeout(() => {
|
||||
if (pendingWidgetPointer) updateWidgetDragPreview(pendingWidgetPointer)
|
||||
pendingWidgetPointer = null
|
||||
widgetPreviewTimer = undefined
|
||||
}, 110)
|
||||
}
|
||||
|
||||
function clearWidgetDragPreview(): void {
|
||||
clearEdgePageTurn()
|
||||
if (widgetPreviewTimer !== undefined) window.clearTimeout(widgetPreviewTimer)
|
||||
widgetPreviewTimer = undefined
|
||||
pendingWidgetPointer = null
|
||||
lastWidgetPointer = null
|
||||
widgetDragGrip.value = null
|
||||
widgetDragSize.value = null
|
||||
widgetDragPreview.value = null
|
||||
}
|
||||
|
||||
function finishWidgetDrag(event: PointerEvent): void {
|
||||
const id = draggingWidgetId.value
|
||||
if (!id) return
|
||||
lastWidgetPointer = { clientX: event.clientX, clientY: event.clientY }
|
||||
updateWidgetDragPreview(lastWidgetPointer)
|
||||
const preview = widgetDragPreview.value
|
||||
if (preview?.id === id) {
|
||||
widgets.move(id, preview.page, preview.column, preview.row)
|
||||
}
|
||||
draggingWidgetId.value = null
|
||||
clearWidgetDragPreview()
|
||||
}
|
||||
|
||||
function stopWidgetDrag(): void {
|
||||
draggingWidgetId.value = null
|
||||
clearWidgetDragPreview()
|
||||
}
|
||||
|
||||
function removeWidget(id: string): void {
|
||||
widgets.remove(id)
|
||||
if (widgetActionId.value === id) widgetActionId.value = null
|
||||
if (widgetConfigId.value === id) widgetConfigId.value = null
|
||||
}
|
||||
|
||||
async function addWidget(kind: WidgetKind, size: WidgetSize): Promise<void> {
|
||||
const targetPage = isEditablePage.value ? phone.currentPage : 1
|
||||
const addedId = widgets.add(kind, size, targetPage)
|
||||
if (!addedId) return
|
||||
widgetPickerOpened.value = false
|
||||
editMode.value = true
|
||||
await nextTick()
|
||||
const added = widgets.layout.instances.find(
|
||||
(instance) => instance.id === addedId,
|
||||
)
|
||||
if (added) changePage(added.page)
|
||||
}
|
||||
|
||||
function openWidgetConfig(): void {
|
||||
widgetConfigId.value = widgetActionId.value
|
||||
widgetActionId.value = null
|
||||
}
|
||||
|
||||
async function saveWidgetConfig(
|
||||
size: WidgetSize,
|
||||
settings: WidgetSettings,
|
||||
): Promise<void> {
|
||||
if (!widgetConfigId.value) return
|
||||
const id = widgetConfigId.value
|
||||
widgets.resize(id, size)
|
||||
widgets.updateSettings(id, settings)
|
||||
widgetConfigId.value = null
|
||||
editMode.value = true
|
||||
await nextTick()
|
||||
const configured = widgets.layout.instances.find(
|
||||
(instance) => instance.id === id,
|
||||
)
|
||||
if (configured) changePage(configured.page)
|
||||
}
|
||||
|
||||
function targetHomeIndex(page: number, cell: number): number {
|
||||
return Math.min(
|
||||
Math.max(0, appStore.homeLayout.grid.length - 1),
|
||||
(page - 1) * HOME_GRID_PAGE_SIZE + cell,
|
||||
)
|
||||
}
|
||||
|
||||
function startHomeDrag(area: HomeArea, index: number): void {
|
||||
draggingHomeApp.value = { area, index }
|
||||
}
|
||||
|
||||
function moveHomeDrag(event: PointerEvent): void {
|
||||
if (draggingHomeApp.value?.area === 'grid') {
|
||||
queueEdgePageTurn(event, 'app')
|
||||
}
|
||||
}
|
||||
|
||||
function finishHomeDrag(event: PointerEvent): void {
|
||||
clearEdgePageTurn()
|
||||
const dragged = draggingHomeApp.value
|
||||
if (!dragged) return
|
||||
const target = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.find((element) => !element.closest('.app-icon-item--dragging'))
|
||||
.find(
|
||||
(element) =>
|
||||
!element.closest('.app-icon-item--dragging') &&
|
||||
(element.closest('[data-home-index]') ||
|
||||
element.closest('[data-home-area]')),
|
||||
)
|
||||
const targetArea = target?.closest<HTMLElement>('[data-home-area]')
|
||||
let targetItem = target?.closest<HTMLElement>('[data-home-index]')
|
||||
if (!targetItem && targetArea) {
|
||||
@@ -191,13 +611,40 @@ function finishHomeDrag(event: PointerEvent): void {
|
||||
}
|
||||
|
||||
function stopHomeDrag(): void {
|
||||
clearEdgePageTurn()
|
||||
draggingHomeApp.value = null
|
||||
}
|
||||
|
||||
async function addHomePage(): Promise<void> {
|
||||
if (addingHomePage.value) return
|
||||
addingHomePage.value = true
|
||||
try {
|
||||
if (!appStore.addHomePage()) return
|
||||
await nextTick()
|
||||
changePage(appPages.value.length)
|
||||
} finally {
|
||||
addingHomePage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrentPage(): Promise<void> {
|
||||
if (!canDeleteCurrentPage.value) return
|
||||
const deletedPage = phone.currentPage
|
||||
const remainingPages = persistedHomePageCount.value - 1
|
||||
widgets.deletePage(deletedPage, remainingPages)
|
||||
appStore.deleteHomePage(deletedPage)
|
||||
await nextTick()
|
||||
changePage(Math.min(deletedPage, appPages.value.length))
|
||||
}
|
||||
|
||||
function removeHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
appStore.removeHomeApp(appId)
|
||||
}
|
||||
|
||||
function restoreHomeApp(appId: LaunchablePhoneAppId): void {
|
||||
appStore.restoreHomeApp(appId)
|
||||
}
|
||||
|
||||
function clearSearch(): void {
|
||||
searchQuery.value = ''
|
||||
searchFocused.value = false
|
||||
@@ -209,7 +656,7 @@ function openAllApps(): void {
|
||||
showAllApps.value = true
|
||||
}
|
||||
|
||||
watch(isAppPage, (visible) => {
|
||||
watch(isEditablePage, (visible) => {
|
||||
if (!visible) editMode.value = false
|
||||
})
|
||||
</script>
|
||||
@@ -222,6 +669,7 @@ watch(isAppPage, (visible) => {
|
||||
{
|
||||
'springboard--dragging': dragging,
|
||||
'springboard--editing': editMode,
|
||||
'springboard--widget-dragging': draggingWidgetId !== null,
|
||||
},
|
||||
]"
|
||||
>
|
||||
@@ -231,57 +679,82 @@ watch(isAppPage, (visible) => {
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="finishPointer"
|
||||
@pointercancel="finishPointer"
|
||||
@pointercancel="cancelPointer"
|
||||
@transitionend.self="finishPageTransition"
|
||||
@transitioncancel.self="finishPageTransition"
|
||||
>
|
||||
<section
|
||||
class="springboard-page springboard-page--widgets"
|
||||
:style="pageStyle"
|
||||
:aria-label="phone.t('Home.widgets.label')"
|
||||
>
|
||||
<SpringboardWidgets />
|
||||
<div class="springboard-widget-page-scroll">
|
||||
<SpringboardWidgetGrid
|
||||
:page="0"
|
||||
:dragging-widget-id="draggingWidgetId"
|
||||
:edit-mode="editMode"
|
||||
@dragcancel="stopWidgetDrag"
|
||||
@dragend="finishWidgetDrag"
|
||||
@dragmove="queueWidgetDragPreview"
|
||||
@dragstart="startWidgetDrag"
|
||||
@menu="openWidgetMenu"
|
||||
@remove="removeWidget"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-for="(apps, pageIndex) in appPages"
|
||||
:key="`apps-${pageIndex}`"
|
||||
v-for="page in appPages"
|
||||
:key="`apps-${page.page}`"
|
||||
class="springboard-page springboard-page--apps"
|
||||
:style="pageStyle"
|
||||
:aria-label="phone.t('Home.apps')"
|
||||
>
|
||||
<div class="app-grid" data-home-area="grid">
|
||||
<SpringboardWidgetGrid
|
||||
:page="page.page"
|
||||
:dragging-widget-id="draggingWidgetId"
|
||||
:edit-mode="editMode"
|
||||
@dragcancel="stopWidgetDrag"
|
||||
@dragend="finishWidgetDrag"
|
||||
@dragmove="queueWidgetDragPreview"
|
||||
@dragstart="startWidgetDrag"
|
||||
@menu="openWidgetMenu"
|
||||
@remove="removeWidget"
|
||||
/>
|
||||
<TransitionGroup
|
||||
:name="
|
||||
draggingWidgetId && !pageTransitioning ? 'app-reflow' : undefined
|
||||
"
|
||||
tag="div"
|
||||
class="app-grid"
|
||||
data-home-area="grid"
|
||||
>
|
||||
<template
|
||||
v-for="(app, appIndex) in apps"
|
||||
:key="
|
||||
app?.id ??
|
||||
`grid-empty-${pageIndex * HOME_GRID_PAGE_SIZE + appIndex}`
|
||||
"
|
||||
v-for="(cell, cellIndex) in page.cells"
|
||||
:key="cell?.app.id ?? `grid-empty-${page.page}-${cellIndex}`"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="app"
|
||||
:app="app"
|
||||
v-if="cell"
|
||||
:app="cell.app"
|
||||
data-home-area="grid"
|
||||
:data-home-index="pageIndex * HOME_GRID_PAGE_SIZE + appIndex"
|
||||
:data-home-index="cell.sourceIndex"
|
||||
:edit-mode="editMode"
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragstart="
|
||||
startHomeDrag(
|
||||
'grid',
|
||||
pageIndex * HOME_GRID_PAGE_SIZE + appIndex,
|
||||
)
|
||||
"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
||||
@edit="enterEditMode"
|
||||
@remove="removeHomeApp(app.id)"
|
||||
@remove="removeHomeApp(cell.app.id)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="app-grid-slot"
|
||||
data-home-area="grid"
|
||||
:data-home-index="pageIndex * HOME_GRID_PAGE_SIZE + appIndex"
|
||||
:data-home-index="targetHomeIndex(page.page, cellIndex)"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -314,6 +787,32 @@ watch(isAppPage, (visible) => {
|
||||
class="app-library-groups"
|
||||
:class="{ 'app-library-groups--behind': showAllApps }"
|
||||
>
|
||||
<article
|
||||
v-if="hiddenApps.length"
|
||||
class="app-library-group app-library-group--removed"
|
||||
>
|
||||
<div class="app-library-group__icons">
|
||||
<div
|
||||
v-for="app in hiddenApps.slice(0, 4)"
|
||||
:key="app.id"
|
||||
class="app-library-restore-item"
|
||||
>
|
||||
<AppIcon :app="app" compact :show-label="false" />
|
||||
<k-button
|
||||
small
|
||||
rounded
|
||||
class="app-library-restore-button"
|
||||
:aria-label="
|
||||
phone.t('Home.addToHome', { app: phone.t(app.labelKey) })
|
||||
"
|
||||
@click.stop="restoreHomeApp(app.id)"
|
||||
>
|
||||
<Plus :size="13" :stroke-width="3" />
|
||||
</k-button>
|
||||
</div>
|
||||
</div>
|
||||
<span>{{ phone.t('Home.removedFromHome') }}</span>
|
||||
</article>
|
||||
<article
|
||||
v-for="group in appGroups"
|
||||
:key="group.key"
|
||||
@@ -366,6 +865,18 @@ watch(isAppPage, (visible) => {
|
||||
>
|
||||
<AppIcon :app="app" compact :show-label="false" />
|
||||
<span>{{ phone.t(app.labelKey) }}</span>
|
||||
<k-button
|
||||
v-if="appStore.homeLayout.hidden.includes(app.id)"
|
||||
small
|
||||
rounded
|
||||
class="app-library-row-restore"
|
||||
:aria-label="
|
||||
phone.t('Home.addToHome', { app: phone.t(app.labelKey) })
|
||||
"
|
||||
@click.stop="restoreHomeApp(app.id)"
|
||||
>
|
||||
<Plus :size="14" :stroke-width="3" />
|
||||
</k-button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="filteredApps.length === 0" class="app-library-empty">
|
||||
@@ -378,7 +889,20 @@ watch(isAppPage, (visible) => {
|
||||
|
||||
<Transition name="edit-done">
|
||||
<k-glass
|
||||
v-if="editMode && isAppPage"
|
||||
v-if="editMode && isEditablePage"
|
||||
component="button"
|
||||
class="springboard-edit-add"
|
||||
type="button"
|
||||
:aria-label="phone.t('Home.widgetSystem.add')"
|
||||
@click="widgetPickerOpened = true"
|
||||
>
|
||||
<Plus :size="20" :stroke-width="2.5" />
|
||||
</k-glass>
|
||||
</Transition>
|
||||
|
||||
<Transition name="edit-done">
|
||||
<k-glass
|
||||
v-if="editMode && isEditablePage"
|
||||
component="button"
|
||||
class="springboard-edit-done"
|
||||
type="button"
|
||||
@@ -409,6 +933,7 @@ watch(isAppPage, (visible) => {
|
||||
:show-label="false"
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('dock', appIndex)"
|
||||
@edit="enterEditMode"
|
||||
@remove="removeHomeApp(app.id)"
|
||||
@@ -425,18 +950,134 @@ watch(isAppPage, (visible) => {
|
||||
</Transition>
|
||||
|
||||
<nav
|
||||
v-if="isAppPage && appPages.length > 1"
|
||||
v-if="phone.currentPage < libraryPage"
|
||||
class="page-indicator"
|
||||
:class="{ 'page-indicator--without-dock': phone.currentPage === 0 }"
|
||||
:aria-label="phone.t('Home.pages')"
|
||||
>
|
||||
<button
|
||||
v-for="(_, pageIndex) in appPages"
|
||||
:key="pageIndex"
|
||||
v-for="pageIndex in appPages.length + 1"
|
||||
:key="pageIndex - 1"
|
||||
type="button"
|
||||
:class="{ active: phone.currentPage === pageIndex + 1 }"
|
||||
:aria-label="`${phone.t('Home.page')} ${pageIndex + 1}`"
|
||||
@click="phone.setCurrentPage(pageIndex + 1, pageCount)"
|
||||
:class="{ active: phone.currentPage === pageIndex - 1 }"
|
||||
:aria-label="`${phone.t('Home.page')} ${pageIndex}`"
|
||||
@click="changePage(pageIndex - 1)"
|
||||
></button>
|
||||
<k-button
|
||||
v-if="editMode && canAddHomePage"
|
||||
small
|
||||
rounded
|
||||
class="springboard-page-add"
|
||||
type="button"
|
||||
:aria-label="phone.t('Home.addPage')"
|
||||
@click="addHomePage"
|
||||
>
|
||||
<Plus :size="12" :stroke-width="2.8" />
|
||||
</k-button>
|
||||
<k-button
|
||||
v-if="editMode && canDeleteCurrentPage"
|
||||
small
|
||||
rounded
|
||||
class="springboard-page-delete"
|
||||
type="button"
|
||||
:aria-label="phone.t('Home.deletePage')"
|
||||
@click="deleteCurrentPage"
|
||||
>
|
||||
<Trash2 :size="12" :stroke-width="2.4" />
|
||||
</k-button>
|
||||
</nav>
|
||||
|
||||
<k-sheet
|
||||
:opened="widgetActionId !== null"
|
||||
class="widget-action-sheet"
|
||||
@backdropclick="widgetActionId = null"
|
||||
>
|
||||
<div class="widget-sheet-handle" />
|
||||
<h3>
|
||||
{{
|
||||
activeWidget
|
||||
? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`)
|
||||
: phone.t('Home.widgets.label')
|
||||
}}
|
||||
</h3>
|
||||
<k-list inset strong class="widget-action-list">
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
:title="phone.t('Home.widgetSystem.editWidget')"
|
||||
@click="openWidgetConfig"
|
||||
>
|
||||
<template #media><Pencil :size="20" /></template>
|
||||
</k-list-item>
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
class="widget-action-remove"
|
||||
:title="phone.t('Home.widgetSystem.removeWidget')"
|
||||
@click="activeWidget && removeWidget(activeWidget.id)"
|
||||
>
|
||||
<template #media><Trash2 :size="20" /></template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
<k-button
|
||||
large
|
||||
rounded
|
||||
class="widget-action-cancel"
|
||||
@click="widgetActionId = null"
|
||||
>
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</k-button>
|
||||
</k-sheet>
|
||||
|
||||
<WidgetPickerSheet
|
||||
:opened="widgetPickerOpened"
|
||||
@add="addWidget"
|
||||
@close="widgetPickerOpened = false"
|
||||
/>
|
||||
<WidgetConfigSheet
|
||||
:instance="configuredWidget"
|
||||
:opened="widgetConfigId !== null"
|
||||
@close="widgetConfigId = null"
|
||||
@save="saveWidgetConfig"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.widget-action-sheet) {
|
||||
z-index: 105;
|
||||
padding: 9px 0 30px;
|
||||
border-radius: 25px 25px 0 0;
|
||||
}
|
||||
|
||||
.widget-sheet-handle {
|
||||
width: 36px;
|
||||
height: 5px;
|
||||
margin: 0 auto 9px;
|
||||
border-radius: 999px;
|
||||
background: rgb(142 142 147 / 45%);
|
||||
}
|
||||
|
||||
.widget-action-sheet h3 {
|
||||
margin: 3px 18px 9px;
|
||||
color: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.widget-action-list {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.widget-action-remove :deep([class*='title']),
|
||||
.widget-action-remove :deep(svg) {
|
||||
color: #ff3b30 !important;
|
||||
}
|
||||
|
||||
.widget-action-cancel {
|
||||
width: calc(100% - 32px);
|
||||
margin: 0 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import {
|
||||
kSegmentedButton,
|
||||
} from 'konsta/vue'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Images,
|
||||
RefreshCw,
|
||||
RotateCcwSquare,
|
||||
@@ -38,7 +39,7 @@ const messageMedia = useMessageMediaStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const requestedMessageMedia = computed<MediaType | null>(() => {
|
||||
const value = route.query.messageAttachment
|
||||
const value = route.query.mediaAttachment ?? route.query.messageAttachment
|
||||
return value === 'photo' || value === 'video' ? value : null
|
||||
})
|
||||
const mode = ref<MediaType>(requestedMessageMedia.value ?? 'photo')
|
||||
@@ -205,6 +206,10 @@ function capture(): void {
|
||||
void requestPhoto()
|
||||
}
|
||||
|
||||
function cancelMediaSelection(): void {
|
||||
void router.replace(messageMedia.cancel())
|
||||
}
|
||||
|
||||
function setMode(nextMode: MediaType): void {
|
||||
if (recording.value || savingVideo.value) return
|
||||
mode.value = nextMode
|
||||
@@ -230,6 +235,9 @@ function toggleOrientation(): void {
|
||||
},
|
||||
'*',
|
||||
)
|
||||
void nuiCall('camera:setOrientation', {
|
||||
landscape: phone.cameraLandscape,
|
||||
})
|
||||
}
|
||||
|
||||
function setZoom(zoom: (typeof zoomLevels)[number]): void {
|
||||
@@ -348,6 +356,7 @@ onMounted(() => {
|
||||
{ data: { landscape: false }, type: 'camera:orientation' },
|
||||
'*',
|
||||
)
|
||||
void nuiCall('camera:setOrientation', { landscape: false })
|
||||
window.postMessage(
|
||||
{ data: { zoom: selectedZoom.value }, type: 'camera:zoom' },
|
||||
'*',
|
||||
@@ -381,6 +390,7 @@ onBeforeUnmount(() => {
|
||||
'*',
|
||||
)
|
||||
window.postMessage({ type: 'camera:recordCancel' }, '*')
|
||||
void nuiCall('camera:setOrientation', { landscape: false })
|
||||
void nuiCall('camera:setFlash', { enabled: false })
|
||||
void nuiCall('camera:setActive', { active: false })
|
||||
})
|
||||
@@ -413,7 +423,17 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<header class="camera-topbar">
|
||||
<button
|
||||
v-if="requestedMessageMedia"
|
||||
class="camera-picker-back"
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.back')"
|
||||
@click="cancelMediaSelection"
|
||||
>
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<k-fab
|
||||
v-if="!requestedMessageMedia"
|
||||
component="button"
|
||||
type="button"
|
||||
class="camera-control"
|
||||
@@ -489,7 +509,7 @@ onBeforeUnmount(() => {
|
||||
router.push({
|
||||
path: '/apps/photos',
|
||||
query: requestedMessageMedia
|
||||
? { messageAttachment: requestedMessageMedia }
|
||||
? { mediaAttachment: requestedMessageMedia }
|
||||
: undefined,
|
||||
})
|
||||
"
|
||||
@@ -671,6 +691,17 @@ onBeforeUnmount(() => {
|
||||
.camera-control {
|
||||
--color-primary: transparent;
|
||||
}
|
||||
.camera-picker-back {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #1c1c1ecc;
|
||||
color: #fff;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.camera-control svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2296
-198
File diff suppressed because it is too large
Load Diff
@@ -41,9 +41,13 @@ const messageMedia = useMessageMediaStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const requestedMessageMedia = computed<GalleryFilter | null>(() => {
|
||||
const value = route.query.messageAttachment
|
||||
const value = route.query.mediaAttachment ?? route.query.messageAttachment
|
||||
return value === 'photo' || value === 'video' ? value : null
|
||||
})
|
||||
const multipleSelection = computed(
|
||||
() => requestedMessageMedia.value !== null && (messageMedia.request?.maxSelection ?? 1) > 1,
|
||||
)
|
||||
const selectedMediaIds = ref<number[]>([])
|
||||
const media = ref<PhoneMedia[]>([])
|
||||
const filter = ref<GalleryFilter>(requestedMessageMedia.value ?? 'all')
|
||||
const loading = ref(true)
|
||||
@@ -203,6 +207,14 @@ function observeMore(): void {
|
||||
|
||||
function openMedia(entry: PhoneMedia): void {
|
||||
if (requestedMessageMedia.value) {
|
||||
if (multipleSelection.value) {
|
||||
const index = selectedMediaIds.value.indexOf(entry.id)
|
||||
if (index >= 0) selectedMediaIds.value.splice(index, 1)
|
||||
else if (selectedMediaIds.value.length < (messageMedia.request?.maxSelection ?? 1)) {
|
||||
selectedMediaIds.value.push(entry.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
const returnPath = messageMedia.complete(entry)
|
||||
if (returnPath) void router.replace(returnPath)
|
||||
return
|
||||
@@ -214,6 +226,15 @@ function openMedia(entry: PhoneMedia): void {
|
||||
imagePan.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
function completeMultipleSelection(): void {
|
||||
const selectedMedia = selectedMediaIds.value.flatMap((id) => {
|
||||
const entry = media.value.find((item) => item.id === id)
|
||||
return entry ? [entry] : []
|
||||
})
|
||||
const returnPath = messageMedia.completeMany(selectedMedia)
|
||||
if (returnPath) void router.replace(returnPath)
|
||||
}
|
||||
|
||||
function cancelMessageSelection(): void {
|
||||
void router.replace(messageMedia.cancel())
|
||||
}
|
||||
@@ -352,6 +373,15 @@ onBeforeUnmount(() => {
|
||||
@click="cancelMessageSelection"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="multipleSelection" #right>
|
||||
<k-link
|
||||
component="button"
|
||||
:disabled="!selectedMediaIds.length"
|
||||
@click="completeMultipleSelection"
|
||||
>
|
||||
{{ phone.t('Common.done') }}
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
|
||||
<div class="gallery-content">
|
||||
@@ -375,6 +405,7 @@ onBeforeUnmount(() => {
|
||||
v-for="entry in media"
|
||||
:key="entry.id"
|
||||
class="gallery-tile"
|
||||
:class="{ 'gallery-tile--selected': selectedMediaIds.includes(entry.id) }"
|
||||
type="button"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
@@ -401,6 +432,12 @@ onBeforeUnmount(() => {
|
||||
<span v-if="entry.mediaType === 'video'" class="gallery-video-badge">
|
||||
<Play :size="16" fill="currentColor" />
|
||||
</span>
|
||||
<span
|
||||
v-if="multipleSelection && selectedMediaIds.includes(entry.id)"
|
||||
class="gallery-selection-badge"
|
||||
>
|
||||
{{ selectedMediaIds.indexOf(entry.id) + 1 }}
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
v-if="hasMore"
|
||||
@@ -594,6 +631,29 @@ onBeforeUnmount(() => {
|
||||
border: 0;
|
||||
background: #d1d1d6;
|
||||
}
|
||||
.gallery-tile--selected::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 3px solid #0a84ff;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gallery-selection-badge {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
background: #0a84ff;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 6px #0006;
|
||||
}
|
||||
.gallery-grid--fill .gallery-tile {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -11,31 +11,51 @@ import {
|
||||
Images,
|
||||
MapPin,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Store,
|
||||
Trash2,
|
||||
UserRound,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
kButton,
|
||||
kGlass,
|
||||
kIcon,
|
||||
kNavbar,
|
||||
kPage,
|
||||
kSearchbar,
|
||||
kTabbar,
|
||||
kTabbarLink,
|
||||
kToolbarPane,
|
||||
} from 'konsta/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
|
||||
import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { usePagesStore } from '@/stores/pages'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PagesCategory, PagesPost } from '@/types/pages'
|
||||
|
||||
type SelectedPhoto = { background: string; id: string }
|
||||
type ComposeDraft = {
|
||||
body: string
|
||||
category: Exclude<PagesCategory, 'citymarkt'>
|
||||
district: string
|
||||
images: string[]
|
||||
title: string
|
||||
}
|
||||
type MediaContext = { draft: ComposeDraft; photos: SelectedPhoto[] }
|
||||
|
||||
type Screen = 'main' | 'detail' | 'compose'
|
||||
type Tab = 'feed' | 'create' | 'profile'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
const media = useMediaStore()
|
||||
const messageMedia = useMessageMediaStore()
|
||||
const pages = usePagesStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const screen = ref<Screen>('main')
|
||||
const tab = ref<Tab>('feed')
|
||||
@@ -46,9 +66,8 @@ const search = ref('')
|
||||
const category = ref<string>('all')
|
||||
const feedback = ref('')
|
||||
const reactionPending = ref(false)
|
||||
const photoSource = ref<'camera' | 'gallery' | null>(null)
|
||||
const cameraFlash = ref(false)
|
||||
const draft = ref({
|
||||
const pickedPhotos = ref<SelectedPhoto[]>([])
|
||||
const draft = ref<ComposeDraft>({
|
||||
body: '',
|
||||
category: 'recommendation' as Exclude<PagesCategory, 'citymarkt'>,
|
||||
district: 'los_santos',
|
||||
@@ -76,10 +95,10 @@ const displayedPosts = computed(() => tab.value === 'profile'
|
||||
: pages.items)
|
||||
const isAuthenticated = computed(() => Boolean(account.email))
|
||||
const selectedPhotos = computed(() => draft.value.images
|
||||
.map((id) => media.photos.find((photo) => photo.id === id))
|
||||
.map((id) => pickedPhotos.value.find((photo) => photo.id === id))
|
||||
.filter((photo) => photo !== undefined))
|
||||
const selectedImages = computed(() => selectedPhotos.value.map((photo, index) => ({
|
||||
gradient: photo.gradient,
|
||||
gradient: photo.background,
|
||||
media_id: photo.id,
|
||||
sort_order: index + 1,
|
||||
})))
|
||||
@@ -110,6 +129,15 @@ async function loadFeed(): Promise<void> {
|
||||
await pages.load({ category: category.value, search: search.value })
|
||||
}
|
||||
|
||||
function updateSearch(event: Event): void {
|
||||
search.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
function clearSearch(): void {
|
||||
search.value = ''
|
||||
void loadFeed()
|
||||
}
|
||||
|
||||
async function selectTab(next: Tab): Promise<void> {
|
||||
if (next === 'create') {
|
||||
if (!isAuthenticated.value) {
|
||||
@@ -134,19 +162,29 @@ async function openPost(post: PagesPost): Promise<void> {
|
||||
|
||||
function togglePhoto(id: string): void {
|
||||
const index = draft.value.images.indexOf(id)
|
||||
if (index >= 0) draft.value.images.splice(index, 1)
|
||||
else if (draft.value.images.length < 6) draft.value.images.push(id)
|
||||
else showFeedback('Apps.localPages.photoLimit')
|
||||
if (index >= 0) {
|
||||
draft.value.images.splice(index, 1)
|
||||
pickedPhotos.value = pickedPhotos.value.filter((photo) => photo.id !== id)
|
||||
}
|
||||
}
|
||||
|
||||
function capturePhoto(): void {
|
||||
if (draft.value.images.length >= 6) {
|
||||
function openMediaApp(app: 'camera' | 'photos'): void {
|
||||
const remaining = 6 - draft.value.images.length
|
||||
if (remaining < 1) {
|
||||
showFeedback('Apps.localPages.photoLimit')
|
||||
return
|
||||
}
|
||||
cameraFlash.value = true
|
||||
draft.value.images.push(media.capture().id)
|
||||
window.setTimeout(() => (cameraFlash.value = false), 120)
|
||||
messageMedia.begin(
|
||||
'local-pages:compose',
|
||||
'photo',
|
||||
'/apps/local-pages?compose=1',
|
||||
app === 'photos' ? remaining : 1,
|
||||
{
|
||||
draft: { ...draft.value, images: [...draft.value.images] },
|
||||
photos: [...pickedPhotos.value],
|
||||
} satisfies MediaContext,
|
||||
)
|
||||
void router.push({ path: `/apps/${app}`, query: { mediaAttachment: 'photo' } })
|
||||
}
|
||||
|
||||
async function publish(): Promise<void> {
|
||||
@@ -166,7 +204,7 @@ async function publish(): Promise<void> {
|
||||
return
|
||||
}
|
||||
draft.value = { body: '', category: 'recommendation', district: 'los_santos', images: [], title: '' }
|
||||
photoSource.value = null
|
||||
pickedPhotos.value = []
|
||||
tab.value = 'feed'
|
||||
screen.value = 'main'
|
||||
showFeedback('Apps.localPages.published')
|
||||
@@ -225,40 +263,72 @@ function openCityMarktListing(): void {
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => void loadFeed())
|
||||
onMounted(() => {
|
||||
const selection = messageMedia.consumeMany<MediaContext>('local-pages:compose')
|
||||
if (selection) {
|
||||
if (selection.context) {
|
||||
draft.value = selection.context.draft
|
||||
pickedPhotos.value = selection.context.photos
|
||||
}
|
||||
for (const media of selection.media) {
|
||||
const id = String(media.id)
|
||||
if (draft.value.images.includes(id) || draft.value.images.length >= 6) continue
|
||||
draft.value.images.push(id)
|
||||
pickedPhotos.value.push({ background: `url(${JSON.stringify(media.url)})`, id })
|
||||
}
|
||||
}
|
||||
if (route.query.compose === '1') screen.value = 'compose'
|
||||
void loadFeed()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="pages" :class="{ 'pages--light': !phone.isDarkMode }">
|
||||
<k-page
|
||||
component="main"
|
||||
class="pages pb-safe-24"
|
||||
:class="{ 'pages--light': !phone.isDarkMode }"
|
||||
:colors="{ bgIos: 'bg-transparent' }"
|
||||
>
|
||||
<template v-if="screen === 'main'">
|
||||
<header class="pages__header">
|
||||
<div>
|
||||
<span class="pages__brand">
|
||||
<MapPin :size="14" />
|
||||
{{ phone.t(tab === 'feed' ? 'Apps.localPages.eyebrow' : 'Apps.localPages.name') }}
|
||||
</span>
|
||||
<h1>{{ phone.t(tab === 'feed' ? 'Apps.localPages.name' : 'Apps.localPages.profile') }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
<k-navbar
|
||||
class="pages-navbar"
|
||||
:subtitle="phone.t(tab === 'feed' ? 'Apps.localPages.eyebrow' : 'Apps.localPages.name')"
|
||||
:title="phone.t(tab === 'feed' ? 'Apps.localPages.name' : 'Apps.localPages.profile')"
|
||||
/>
|
||||
|
||||
<section class="pages__content">
|
||||
<template v-if="tab === 'feed'">
|
||||
<div class="pages__hero"><div><small>{{ phone.t('Apps.localPages.cityPulse') }}</small><strong>{{ phone.t('Apps.localPages.heroTitle') }}</strong><span>{{ phone.t('Apps.localPages.heroBody') }}</span></div><MapPin :size="40" /></div>
|
||||
<form class="pages__search" @submit.prevent="loadFeed"><Search :size="17" /><input v-model="search" :placeholder="phone.t('Apps.localPages.searchPlaceholder')" /><button>{{ phone.t('Apps.localPages.search') }}</button></form>
|
||||
<k-glass class="pages-hero-glass">
|
||||
<div class="pages__hero"><div><small>{{ phone.t('Apps.localPages.cityPulse') }}</small><strong>{{ phone.t('Apps.localPages.heroTitle') }}</strong><span>{{ phone.t('Apps.localPages.heroBody') }}</span></div><MapPin :size="40" /></div>
|
||||
</k-glass>
|
||||
<k-searchbar
|
||||
component="form"
|
||||
class="pages-searchbar"
|
||||
:value="search"
|
||||
:placeholder="phone.t('Apps.localPages.searchPlaceholder')"
|
||||
@input="updateSearch"
|
||||
@clear="clearSearch"
|
||||
@submit.prevent="loadFeed"
|
||||
/>
|
||||
<CityMarktSelect :model-value="category" :options="categoryOptions" @change="(value) => { category = value; loadFeed() }" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="!isAuthenticated" class="pages__empty"><UserRound :size="42" /><strong>{{ phone.t('Apps.localPages.signInTitle') }}</strong><span>{{ phone.t('Apps.localPages.signInBody') }}</span></div>
|
||||
<template v-else>
|
||||
<div class="pages__profile"><span>{{ account.email.charAt(0).toUpperCase() }}</span><div><small>{{ phone.t('Apps.localPages.localCreator') }}</small><strong>@{{ account.email.split('@')[0] }}</strong><b>{{ pages.ownItems.length }} {{ phone.t('Apps.localPages.posts') }}</b></div></div>
|
||||
<div class="pages__segmented"><button :class="{ active: profileMode === 'own' }" @click="profileMode = 'own'">{{ phone.t('Apps.localPages.myPosts') }}</button><button :class="{ active: profileMode === 'saved' }" @click="profileMode = 'saved'">{{ phone.t('Apps.localPages.saved') }}</button></div>
|
||||
<k-glass class="pages-profile-glass">
|
||||
<div class="pages__profile"><span>{{ account.email.charAt(0).toUpperCase() }}</span><div><small>{{ phone.t('Apps.localPages.localCreator') }}</small><strong>@{{ account.email.split('@')[0] }}</strong><b>{{ pages.ownItems.length }} {{ phone.t('Apps.localPages.posts') }}</b></div></div>
|
||||
</k-glass>
|
||||
<k-glass class="pages-segmented-glass">
|
||||
<div class="pages__segmented"><button :class="{ active: profileMode === 'own' }" @click="profileMode = 'own'">{{ phone.t('Apps.localPages.myPosts') }}</button><button :class="{ active: profileMode === 'saved' }" @click="profileMode = 'saved'">{{ phone.t('Apps.localPages.saved') }}</button></div>
|
||||
</k-glass>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div v-if="pages.isLoading" class="pages__empty">{{ phone.t('Common.loading') }}</div>
|
||||
<div v-else-if="isAuthenticated || tab === 'feed'" class="pages__feed">
|
||||
<article v-for="post in displayedPosts" :key="post.id" class="pages__post">
|
||||
<k-glass v-for="post in displayedPosts" :key="post.id" class="pages-post-glass">
|
||||
<article class="pages__post">
|
||||
<button class="pages__post-open" type="button" @click="openPost(post)">
|
||||
<div class="pages__post-head"><span>{{ post.author_name.charAt(0).toUpperCase() }}</span><div><strong>@{{ post.author_name }}</strong><small><MapPin :size="10" /> {{ post.district ? phone.t(`Apps.citymarkt.districts.${post.district}`) : phone.t('Apps.localPages.allLosSantos') }} · {{ relativeDate(post.created_at) }}</small></div><i>{{ label('categories', post.category) }}</i></div>
|
||||
<div v-if="post.image" class="pages__cover" :style="{ background: post.image }"><b v-if="post.images.length > 1">1 / {{ post.images.length }}</b></div>
|
||||
@@ -269,20 +339,53 @@ onMounted(() => void loadFeed())
|
||||
<span v-if="post.source_type === 'citymarkt'"><Store :size="14" /> CityMarkt</span>
|
||||
<button type="button" :class="{ active: post.is_saved }" :disabled="reactionPending" :aria-label="phone.t('Apps.localPages.save')" @click="reactToPost(post, 'save')"><Bookmark :size="15" :fill="post.is_saved ? 'currentColor' : 'none'" /> {{ phone.t('Apps.localPages.save') }}</button>
|
||||
</div>
|
||||
</article>
|
||||
</article>
|
||||
</k-glass>
|
||||
<div v-if="!displayedPosts.length" class="pages__empty"><Compass :size="38" /><strong>{{ phone.t('Apps.localPages.noPosts') }}</strong><span>{{ phone.t('Apps.localPages.noPostsBody') }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="pages__tabbar">
|
||||
<button :class="{ active: tab === 'feed' }" @click="selectTab('feed')"><span><Compass :size="20" /></span>{{ phone.t('Apps.localPages.discover') }}</button>
|
||||
<button class="create" @click="selectTab('create')"><span><Plus :size="23" /></span>{{ phone.t('Apps.localPages.create') }}</button>
|
||||
<button :class="{ active: tab === 'profile' }" @click="selectTab('profile')"><span><UserRound :size="20" /></span>{{ phone.t('Apps.localPages.profile') }}</button>
|
||||
</nav>
|
||||
<k-tabbar
|
||||
component="nav"
|
||||
icons
|
||||
labels
|
||||
class="bottom-0 left-0 fixed"
|
||||
inner-class="!w-full !max-w-none !gap-0 !px-1"
|
||||
:aria-label="phone.t('Apps.localPages.name')"
|
||||
>
|
||||
<k-toolbar-pane class="pages__tab-pane">
|
||||
<k-tabbar-link
|
||||
component="button"
|
||||
:active="tab === 'feed'"
|
||||
:link-props="{ class: 'pages-tab-button', type: 'button' }"
|
||||
@click="selectTab('feed')"
|
||||
>
|
||||
<template #label><span class="pages__tab-label">{{ phone.t('Apps.localPages.discover') }}</span></template>
|
||||
<template #icon><k-icon><Compass :size="20" /></k-icon></template>
|
||||
</k-tabbar-link>
|
||||
<k-tabbar-link
|
||||
component="button"
|
||||
:link-props="{ class: 'pages-tab-button', type: 'button' }"
|
||||
@click="selectTab('create')"
|
||||
>
|
||||
<template #label><span class="pages__tab-label">{{ phone.t('Apps.localPages.create') }}</span></template>
|
||||
<template #icon><span class="pages__tab-icon pages__tab-icon--create"><k-icon><Plus :size="21" /></k-icon></span></template>
|
||||
</k-tabbar-link>
|
||||
<k-tabbar-link
|
||||
component="button"
|
||||
:active="tab === 'profile'"
|
||||
:link-props="{ class: 'pages-tab-button', type: 'button' }"
|
||||
@click="selectTab('profile')"
|
||||
>
|
||||
<template #label><span class="pages__tab-label">{{ phone.t('Apps.localPages.profile') }}</span></template>
|
||||
<template #icon><k-icon><UserRound :size="20" /></k-icon></template>
|
||||
</k-tabbar-link>
|
||||
</k-toolbar-pane>
|
||||
</k-tabbar>
|
||||
</template>
|
||||
|
||||
<section v-else-if="screen === 'detail' && selected" class="pages__detail">
|
||||
<header><button @click="screen = 'main'"><ArrowLeft :size="20" /></button><strong>{{ phone.t('Apps.localPages.post') }}</strong><button v-if="selected.is_owner" class="danger" @click="removePost"><Trash2 :size="18" /></button><button v-else @click="react('save')"><Bookmark :size="18" :fill="selected.is_saved ? 'currentColor' : 'none'" /></button></header>
|
||||
<header><k-button component="button" clear rounded @click="screen = 'main'"><ArrowLeft :size="20" /></k-button><strong>{{ phone.t('Apps.localPages.post') }}</strong><k-button v-if="selected.is_owner" component="button" clear rounded class="danger" @click="removePost"><Trash2 :size="18" /></k-button><k-button v-else component="button" clear rounded @click="react('save')"><Bookmark :size="18" :fill="selected.is_saved ? 'currentColor' : 'none'" /></k-button></header>
|
||||
<div class="pages__detail-scroll">
|
||||
<div v-if="selected.images.length" class="pages__gallery" :style="{ background: selected.images[galleryIndex]?.gradient }"><button v-if="selected.images.length > 1" @click="moveGallery(-1)"><ChevronLeft /></button><button v-if="selected.images.length > 1" @click="moveGallery(1)"><ChevronRight /></button><span>{{ galleryIndex + 1 }} / {{ selected.images.length }}</span></div>
|
||||
<article><div class="pages__author"><span>{{ selected.author_name.charAt(0).toUpperCase() }}</span><div><strong>@{{ selected.author_name }}</strong><small>{{ relativeDate(selected.created_at) }}</small></div><i>{{ label('categories', selected.category) }}</i></div><h1>{{ selected.title }}</h1><p>{{ selected.body }}</p><div class="pages__location"><MapPin :size="17" /><div><small>{{ phone.t('Apps.localPages.location') }}</small><strong>{{ selected.district ? phone.t(`Apps.citymarkt.districts.${selected.district}`) : phone.t('Apps.localPages.allLosSantos') }}</strong></div></div><button v-if="selected.source_type === 'citymarkt'" class="pages__market-link" @click="openCityMarktListing"><Store :size="18" /><span><small>{{ phone.t('Apps.localPages.sharedFrom') }}</small><strong>{{ phone.t('Apps.localPages.openCityMarkt') }}</strong></span><b v-if="selected.citymarkt_price">${{ Number(selected.citymarkt_price).toLocaleString() }}</b></button></article>
|
||||
@@ -291,26 +394,44 @@ onMounted(() => void loadFeed())
|
||||
</section>
|
||||
|
||||
<section v-else class="pages__compose">
|
||||
<header><button @click="screen = 'main'"><X :size="20" /></button><div><small>{{ phone.t('Apps.localPages.newPost') }}</small><strong>{{ phone.t('Apps.localPages.shareWithCity') }}</strong></div><button :disabled="!canPublish" @click="publish"><Send :size="16" />{{ phone.t('Apps.localPages.publish') }}</button></header>
|
||||
<k-navbar
|
||||
class="pages-create-navbar"
|
||||
center-title
|
||||
left-class="pages-create-action pages-create-action--close !w-11 !min-w-11 !max-w-11 !h-11 !p-0 !rounded-full"
|
||||
right-class="pages-create-action pages-create-action--publish !min-w-[58px] !h-11 !p-0 !rounded-full"
|
||||
:title="phone.t('Apps.localPages.shareWithCity')"
|
||||
:subtitle="phone.t('Apps.localPages.newPost')"
|
||||
>
|
||||
<template #left>
|
||||
<button class="pages-create-close" type="button" :aria-label="phone.t('Common.close')" @click="screen = 'main'">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</template>
|
||||
<template #right>
|
||||
<button class="pages-create-publish" type="button" :disabled="!canPublish" @click="publish">
|
||||
{{ phone.t('Apps.localPages.publish') }}
|
||||
</button>
|
||||
</template>
|
||||
</k-navbar>
|
||||
<div class="pages__compose-scroll">
|
||||
<label>{{ phone.t('Apps.localPages.title') }} <span :class="{ valid: draft.title.trim().length >= 5 }">{{ draft.title.trim().length }}/80 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '5' }) }}</span><input v-model="draft.title" maxlength="80" :placeholder="phone.t('Apps.localPages.titlePlaceholder')" /></label>
|
||||
<label>{{ phone.t('Apps.localPages.body') }} <span :class="{ valid: draft.body.trim().length >= 10 }">{{ draft.body.trim().length }}/1500 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '10' }) }}</span><textarea v-model="draft.body" maxlength="1500" :placeholder="phone.t('Apps.localPages.bodyPlaceholder')" /></label>
|
||||
<label>{{ phone.t('Apps.localPages.title') }} <span :class="{ valid: draft.title.trim().length >= 5 }">{{ draft.title.trim().length }}/80 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '5' }) }}</span><k-glass class="pages__field-glass"><input v-model="draft.title" maxlength="80" :placeholder="phone.t('Apps.localPages.titlePlaceholder')" /></k-glass></label>
|
||||
<label>{{ phone.t('Apps.localPages.body') }} <span :class="{ valid: draft.body.trim().length >= 10 }">{{ draft.body.trim().length }}/1500 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '10' }) }}</span><k-glass class="pages__field-glass pages__field-glass--textarea"><textarea v-model="draft.body" maxlength="1500" :placeholder="phone.t('Apps.localPages.bodyPlaceholder')" /></k-glass></label>
|
||||
<div class="pages__form-row"><label>{{ phone.t('Apps.localPages.category') }}<CityMarktSelect :model-value="draft.category" :options="composeCategoryOptions" @change="(value) => draft.category = value as typeof draft.category" /></label><label>{{ phone.t('Apps.localPages.location') }}<CityMarktSelect :model-value="draft.district" :options="districtOptions" @change="(value) => draft.district = value" /></label></div>
|
||||
<section class="pages__photos">
|
||||
<ImagePlus :size="30" />
|
||||
<h2>{{ phone.t('Apps.citymarkt.addPhotos') }}</h2>
|
||||
<p>{{ phone.t('Apps.citymarkt.addPhotosBody') }}</p>
|
||||
<div class="pages__photo-actions">
|
||||
<button type="button" @click="photoSource = 'gallery'">
|
||||
<k-glass><button type="button" @click="openMediaApp('photos')">
|
||||
<span><Images :size="20" /></span>
|
||||
<strong>{{ phone.t('Apps.citymarkt.chooseGallery') }}</strong>
|
||||
<small>{{ phone.t('Apps.citymarkt.chooseGalleryBody') }}</small>
|
||||
</button>
|
||||
<button type="button" @click="photoSource = 'camera'">
|
||||
</button></k-glass>
|
||||
<k-glass><button type="button" @click="openMediaApp('camera')">
|
||||
<span><Camera :size="20" /></span>
|
||||
<strong>{{ phone.t('Apps.citymarkt.takePhotos') }}</strong>
|
||||
<small>{{ phone.t('Apps.citymarkt.takePhotosBody') }}</small>
|
||||
</button>
|
||||
</button></k-glass>
|
||||
</div>
|
||||
<div class="pages__selected-heading">
|
||||
<strong>{{ phone.t('Apps.citymarkt.selectedPhotos') }}</strong>
|
||||
@@ -330,27 +451,9 @@ onMounted(() => void loadFeed())
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="photoSource" class="pages__photo-source">
|
||||
<header>
|
||||
<div><small>{{ phone.t('Apps.citymarkt.addPhotos') }}</small><strong>{{ phone.t(photoSource === 'gallery' ? 'Apps.citymarkt.gallery' : 'Apps.citymarkt.camera') }}</strong></div>
|
||||
<span>{{ draft.images.length }} / 6</span>
|
||||
<button type="button" :aria-label="phone.t('Common.close')" @click="photoSource = null"><X :size="18" /></button>
|
||||
</header>
|
||||
<div v-if="photoSource === 'gallery'" class="pages__photo-picker">
|
||||
<button v-for="photo in media.photos" :key="photo.id" type="button" :class="{ active: draft.images.includes(photo.id) }" :style="{ background: photo.gradient }" @click="togglePhoto(photo.id)"><i v-if="draft.images.includes(photo.id)">{{ draft.images.indexOf(photo.id) + 1 }}</i></button>
|
||||
</div>
|
||||
<div v-else class="pages__capture">
|
||||
<div class="pages__viewfinder" :style="{ background: media.photos[0]?.gradient }">
|
||||
<i v-for="corner in ['tl', 'tr', 'bl', 'br']" :key="corner" :class="`corner-${corner}`" />
|
||||
<span class="pages__camera-flash" :class="{ active: cameraFlash }" />
|
||||
</div>
|
||||
<p>{{ phone.t('Apps.citymarkt.cameraHint') }}</p>
|
||||
<button class="pages__shutter" type="button" :aria-label="phone.t('Apps.citymarkt.takePhoto')" @click="capturePhoto"><Camera :size="23" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Transition name="toast"><div v-if="feedback" class="pages__toast">{{ feedback }}</div></Transition>
|
||||
</main>
|
||||
</k-page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -395,4 +498,220 @@ onMounted(() => void loadFeed())
|
||||
.pages__selected-heading span,.pages__photo-source>header small,.pages__photo-source>header>span{font-size:11px}
|
||||
.pages__selected-strip button i,.pages__photo-picker i{font-size:10px}
|
||||
.pages__toast{font-size:12px}
|
||||
.pages {
|
||||
--color-primary: var(--yellow);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background: #12171b !important;
|
||||
}
|
||||
.pages--light {
|
||||
background: #fbfbf6 !important;
|
||||
}
|
||||
.pages-navbar {
|
||||
--k-safe-area-top: 46px;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
.pages__content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
height: auto;
|
||||
padding: 108px 13px 112px;
|
||||
}
|
||||
.pages-hero-glass,
|
||||
.pages-profile-glass,
|
||||
.pages-segmented-glass,
|
||||
.pages-post-glass {
|
||||
width: 100%;
|
||||
border-radius: 17px;
|
||||
}
|
||||
.pages-hero-glass {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.pages__hero {
|
||||
height: 105px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.pages-searchbar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pages-profile-glass {
|
||||
margin: 3px 0 10px;
|
||||
}
|
||||
.pages__profile {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.pages-segmented-glass {
|
||||
padding: 4px;
|
||||
border-radius: 13px;
|
||||
}
|
||||
.pages__segmented {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.pages-post-glass {
|
||||
overflow: hidden;
|
||||
}
|
||||
.pages__post {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.pages__tab-pane {
|
||||
width: 100% !important;
|
||||
max-width: none;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
justify-content: space-around;
|
||||
gap: 2px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.pages__tab-label {
|
||||
display: block;
|
||||
max-width: 52px;
|
||||
overflow: hidden;
|
||||
font-size: 9.5px;
|
||||
line-height: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
:global(.pages__tab-pane) {
|
||||
width: 100% !important;
|
||||
max-width: none;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
justify-content: space-around;
|
||||
}
|
||||
:global(.pages-tab-button) {
|
||||
width: 20% !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 58px !important;
|
||||
flex: 0 0 20% !important;
|
||||
padding-right: 3px !important;
|
||||
padding-left: 3px !important;
|
||||
}
|
||||
.pages-create-navbar {
|
||||
--k-safe-area-top: 46px;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
.pages-create-action {
|
||||
height: 44px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.pages-create-action--close {
|
||||
width: 44px;
|
||||
}
|
||||
.pages-create-action--publish {
|
||||
min-width: 58px;
|
||||
}
|
||||
.pages-create-close,
|
||||
.pages-create-publish {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.pages-create-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.pages-create-publish {
|
||||
min-width: 58px;
|
||||
padding: 0 13px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.pages-create-publish:disabled {
|
||||
opacity: 0.38;
|
||||
}
|
||||
.pages__compose-scroll {
|
||||
position: absolute;
|
||||
top: 104px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: auto;
|
||||
padding-bottom: 35px;
|
||||
}
|
||||
.pages__field-glass {
|
||||
min-height: 44px;
|
||||
margin-top: 5px;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pages__field-glass--textarea {
|
||||
min-height: 116px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.pages__field-glass > input,
|
||||
.pages__field-glass > textarea {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin: 0 !important;
|
||||
padding: 11px 14px !important;
|
||||
border: 0 !important;
|
||||
outline: 0;
|
||||
background: transparent !important;
|
||||
color: inherit;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
.pages__field-glass > textarea {
|
||||
min-height: 116px;
|
||||
resize: none;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.pages__photo-actions > * {
|
||||
min-width: 0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.pages__photo-actions > * > button {
|
||||
width: 100%;
|
||||
min-height: 118px;
|
||||
padding: 11px 9px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
}
|
||||
.pages__photo-actions > * > button > span {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--yellow);
|
||||
}
|
||||
.pages__tab-icon {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.pages__tab-icon--create {
|
||||
width: 38px;
|
||||
height: 30px;
|
||||
margin-top: -4px;
|
||||
border-radius: 10px;
|
||||
background: var(--yellow);
|
||||
color: #17191a;
|
||||
box-shadow: 0 4px 12px #00000030;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -123,8 +123,12 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="memory-app" :aria-label="phone.t('Apps.memory.name')">
|
||||
<header class="memory-header">
|
||||
<main
|
||||
class="memory-app"
|
||||
:class="{ 'memory-app--playing': game }"
|
||||
:aria-label="phone.t('Apps.memory.name')"
|
||||
>
|
||||
<header v-if="!game" class="memory-header">
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.memory.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.memory.name') }}</h1>
|
||||
@@ -278,6 +282,10 @@ onBeforeUnmount(() => {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.memory-app--playing {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.memory-header {
|
||||
height: 52px;
|
||||
display: flex;
|
||||
@@ -405,50 +413,67 @@ onBeforeUnmount(() => {
|
||||
.memory-difficulty__best { align-self: start; color: #74698f; font-size: 11.5px; line-height: 1.2; }
|
||||
.memory-difficulties svg { grid-column: 3; grid-row: 1 / 3; color: #7658c7; }
|
||||
|
||||
.memory-game { padding-top: 5px; }
|
||||
|
||||
.memory-stats {
|
||||
height: 50px;
|
||||
display: grid;
|
||||
grid-template-columns: 30px auto auto 1fr;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
.memory-game {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.memory-stats div { display: grid; }
|
||||
.memory-stats span { color: #74698f; font-size: 10.5px; font-weight: 800; text-transform: uppercase; }
|
||||
.memory-stats strong { font-size: 19px; line-height: 1.05; }
|
||||
.memory-stats button { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 14px; font-weight: 800; }
|
||||
.memory-stats {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
top: 66px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 1fr auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid rgb(105 79 160 / 10%);
|
||||
border-radius: 21px;
|
||||
background: rgb(246 241 255 / 64%);
|
||||
box-shadow: 0 8px 24px rgb(75 52 121 / 13%);
|
||||
backdrop-filter: blur(14px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.memory-stats div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
|
||||
.memory-stats span { color: #74698f; font-size: 10.5px; font-weight: 800; line-height: 10px; text-transform: uppercase; }
|
||||
.memory-stats strong { display: block; font-size: 19px; line-height: 20px; }
|
||||
.memory-stats button { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 13px; font-weight: 800; }
|
||||
.memory-stats .memory-menu-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
justify-self: start;
|
||||
padding: 0;
|
||||
border: 1px solid rgb(105 79 160 / 10%);
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 48%);
|
||||
box-shadow: 0 3px 8px rgb(75 52 121 / 8%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-board {
|
||||
position: relative;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 9px;
|
||||
border: 1px solid rgb(101 77 153 / 9%);
|
||||
border-radius: 20px;
|
||||
background: rgb(255 255 255 / 31%);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 50%);
|
||||
align-content: center;
|
||||
padding: 108px 17px 28px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background:
|
||||
radial-gradient(circle at 12% 88%, rgb(135 105 207 / 12%), transparent 31%),
|
||||
radial-gradient(circle at 88% 16%, rgb(255 255 255 / 54%), transparent 28%);
|
||||
perspective: 900px;
|
||||
}
|
||||
|
||||
.memory-board--small { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.memory-board--medium,
|
||||
.memory-board--large { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.memory-board--large { gap: 6px; padding: 7px; }
|
||||
.memory-board--large { gap: 6px; padding-right: 15px; padding-left: 15px; }
|
||||
|
||||
.memory-game-card {
|
||||
aspect-ratio: 0.82;
|
||||
@@ -545,7 +570,7 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border-radius: 20px;
|
||||
border-radius: 0;
|
||||
background: rgb(244 239 255 / 90%);
|
||||
backdrop-filter: blur(6px);
|
||||
text-align: center;
|
||||
|
||||
@@ -198,8 +198,12 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="minesweeper-app" :aria-label="phone.t('Apps.minesweeper.name')">
|
||||
<header class="minesweeper-header">
|
||||
<main
|
||||
class="minesweeper-app"
|
||||
:class="{ 'minesweeper-app--playing': !minesweeper.menuOpen && game }"
|
||||
:aria-label="phone.t('Apps.minesweeper.name')"
|
||||
>
|
||||
<header v-if="minesweeper.menuOpen" class="minesweeper-header">
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.minesweeper.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.minesweeper.name') }}</h1>
|
||||
@@ -420,6 +424,10 @@ onBeforeUnmount(() => {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.minesweeper-app--playing {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.minesweeper-header {
|
||||
height: 55px;
|
||||
display: flex;
|
||||
@@ -524,22 +532,59 @@ onBeforeUnmount(() => {
|
||||
.minesweeper-difficulties small { align-self: start; color: #658a89; font-size: 8px; }
|
||||
.minesweeper-long-press { margin: 0; color: #628887; font-size: 9px; }
|
||||
|
||||
.minesweeper-game { position: relative; padding-top: 5px; }
|
||||
.minesweeper-toolbar { height: 46px; display: grid; grid-template-columns: 36px 1fr 1fr 36px; align-items: center; gap: 7px; }
|
||||
.minesweeper-toolbar div { display: grid; justify-items: center; line-height: 1.05; }
|
||||
.minesweeper-toolbar span { color: #5a8484; font-size: 8px; font-weight: 800; text-transform: uppercase; }
|
||||
.minesweeper-toolbar strong { font-size: 16px; }
|
||||
.minesweeper-game {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 12% 88%, rgb(26 129 130 / 14%), transparent 32%),
|
||||
radial-gradient(circle at 88% 16%, rgb(238 255 249 / 45%), transparent 29%);
|
||||
}
|
||||
|
||||
.minesweeper-toolbar {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 66px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 1fr 32px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid rgb(23 83 87 / 10%);
|
||||
border-radius: 21px;
|
||||
background: rgb(226 249 242 / 65%);
|
||||
box-shadow: 0 8px 24px rgb(23 73 75 / 13%);
|
||||
backdrop-filter: blur(14px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.minesweeper-toolbar div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
|
||||
.minesweeper-toolbar span { color: #5a8484; font-size: 10px; font-weight: 800; line-height: 10px; text-transform: uppercase; }
|
||||
.minesweeper-toolbar strong { display: block; font-size: 18px; line-height: 20px; }
|
||||
|
||||
.minesweeper-toolbar .minesweeper-toolbar__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.minesweeper-board {
|
||||
position: relative;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 14px;
|
||||
left: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--minesweeper-columns), minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
padding: 7px;
|
||||
border: 1px solid rgb(16 78 82 / 10%);
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
background: #187a7e;
|
||||
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 15px 27px rgb(20 79 81 / 18%);
|
||||
transform: translateY(-45%);
|
||||
}
|
||||
|
||||
.minesweeper-cell {
|
||||
@@ -582,8 +627,7 @@ onBeforeUnmount(() => {
|
||||
.minesweeper-game--exploding::after {
|
||||
position: absolute;
|
||||
z-index: 9;
|
||||
inset: 46px 0 0;
|
||||
border-radius: 18px;
|
||||
inset: 0;
|
||||
background: #ffb341;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
@@ -767,13 +811,13 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
@keyframes minesweeper-board-shake {
|
||||
0%, 100% { transform: translate(0); }
|
||||
12% { transform: translate(-7px, 3px) rotate(-1deg); }
|
||||
24% { transform: translate(6px, -4px) rotate(1deg); }
|
||||
38% { transform: translate(-5px, -2px); }
|
||||
52% { transform: translate(4px, 3px); }
|
||||
68% { transform: translate(-2px, -2px); }
|
||||
82% { transform: translate(2px, 1px); }
|
||||
0%, 100% { transform: translate(0, -45%); }
|
||||
12% { transform: translate(-7px, calc(-45% + 3px)) rotate(-1deg); }
|
||||
24% { transform: translate(6px, calc(-45% - 4px)) rotate(1deg); }
|
||||
38% { transform: translate(-5px, calc(-45% - 2px)); }
|
||||
52% { transform: translate(4px, calc(-45% + 3px)); }
|
||||
68% { transform: translate(-2px, calc(-45% - 2px)); }
|
||||
82% { transform: translate(2px, calc(-45% + 1px)); }
|
||||
}
|
||||
|
||||
@keyframes minesweeper-bomb-drop {
|
||||
@@ -866,7 +910,22 @@ onBeforeUnmount(() => {
|
||||
.minesweeper-secondary { position: relative; z-index: 1; min-width: 155px; min-height: 40px; border-radius: 13px; font-size: 11px; font-weight: 850; pointer-events: auto; }
|
||||
.minesweeper-primary { border: 0; color: #104d51; background: #8ce3d2; }
|
||||
.minesweeper-secondary { border: 1px solid rgb(255 255 255 / 13%); color: #e6faf5; background: rgb(255 255 255 / 7%); }
|
||||
.minesweeper-game__hint { margin: 9px 0 0; color: #668c8c; font-size: 9px; text-align: center; }
|
||||
.minesweeper-game__hint {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
right: 45px;
|
||||
bottom: 27px;
|
||||
left: 45px;
|
||||
margin: 0;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
color: #668c8c;
|
||||
background: rgb(226 249 242 / 52%);
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 9px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
button:active { transform: scale(0.96); }
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
ChevronsDown,
|
||||
Pause,
|
||||
Play,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Trophy,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
@@ -201,8 +196,12 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="neon-drop-app" :aria-label="phone.t('Apps.neonDrop.name')">
|
||||
<header class="neon-header">
|
||||
<main
|
||||
class="neon-drop-app"
|
||||
:class="{ 'neon-drop-app--playing': !neon.menuOpen && game }"
|
||||
:aria-label="phone.t('Apps.neonDrop.name')"
|
||||
>
|
||||
<header v-if="neon.menuOpen" class="neon-header">
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.neonDrop.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.neonDrop.name') }}</h1>
|
||||
@@ -335,51 +334,11 @@ onBeforeUnmount(() => {
|
||||
<span>{{ phone.t('Apps.neonDrop.level') }}</span
|
||||
><strong>{{ game.level }}</strong>
|
||||
<div class="neon-level">
|
||||
<i :style="{ height: `${levelProgress * 100}%` }"></i>
|
||||
<i :style="{ width: `${levelProgress * 100}%` }"></i>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="neon-controls"
|
||||
:aria-label="phone.t('Apps.neonDrop.controls')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.neonDrop.left')"
|
||||
@click="move(-1)"
|
||||
>
|
||||
<ArrowLeft :size="18" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.neonDrop.rotate')"
|
||||
@click="rotate"
|
||||
>
|
||||
<RotateCw :size="18" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.neonDrop.right')"
|
||||
@click="move(1)"
|
||||
>
|
||||
<ArrowRight :size="18" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.neonDrop.softDrop')"
|
||||
@click="softDrop"
|
||||
>
|
||||
<ArrowDown :size="18" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.neonDrop.hardDrop')"
|
||||
@click="hardDrop"
|
||||
>
|
||||
<ChevronsDown :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="neon-hint">{{ phone.t('Apps.neonDrop.gameHint') }}</p>
|
||||
|
||||
<div v-if="game.status === 'paused'" class="neon-overlay">
|
||||
@@ -421,6 +380,9 @@ onBeforeUnmount(() => {
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.neon-drop-app--playing {
|
||||
padding: 0;
|
||||
}
|
||||
.neon-header {
|
||||
height: 54px;
|
||||
display: flex;
|
||||
@@ -613,42 +575,70 @@ onBeforeUnmount(() => {
|
||||
background: #ffffff0a;
|
||||
}
|
||||
.neon-game {
|
||||
position: relative;
|
||||
height: calc(100% - 54px);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.neon-toolbar {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 66px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 35px 1fr 1fr 35px;
|
||||
grid-template-columns: 32px 1fr 1fr 32px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid #7cf6e72b;
|
||||
border-radius: 21px;
|
||||
background: #101a38a8;
|
||||
box-shadow: 0 8px 24px #0008;
|
||||
backdrop-filter: blur(14px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.neon-toolbar span {
|
||||
font-size: 11px;
|
||||
line-height: 10px;
|
||||
}
|
||||
.neon-toolbar div {
|
||||
text-align: center;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
grid-template-rows: 10px 20px;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
}
|
||||
.neon-toolbar strong {
|
||||
font-size: 21px;
|
||||
display: block;
|
||||
font-size: 19px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.neon-toolbar button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: none;
|
||||
}
|
||||
.neon-play-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.neon-board {
|
||||
width: 218px;
|
||||
height: 414px;
|
||||
position: absolute;
|
||||
top: 156px;
|
||||
right: 0;
|
||||
bottom: 76px;
|
||||
left: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
grid-template-rows: repeat(18, 1fr);
|
||||
gap: 1.5px;
|
||||
padding: 5px;
|
||||
border: 1px solid #65f4e43b;
|
||||
border-radius: 13px;
|
||||
background: #06091af2;
|
||||
box-shadow:
|
||||
inset 0 0 25px #000b,
|
||||
0 12px 26px #0008;
|
||||
grid-template-rows: repeat(17, 1fr);
|
||||
gap: 1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.neon-cell {
|
||||
@@ -700,31 +690,35 @@ onBeforeUnmount(() => {
|
||||
animation: neon-clear 0.24s ease-out;
|
||||
}
|
||||
.neon-side {
|
||||
width: 70px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
top: 110px;
|
||||
right: 14px;
|
||||
left: 14px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 40px auto auto 1fr;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 10px 4px 11px;
|
||||
border: 1px solid #65f4e448;
|
||||
border-radius: 13px;
|
||||
background: linear-gradient(180deg, #1420478f, #090d24a3);
|
||||
box-shadow: inset 0 0 16px #53e8d90a;
|
||||
gap: 8px;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.neon-side > strong {
|
||||
font-size: 28px;
|
||||
font-size: 21px;
|
||||
}
|
||||
.neon-preview {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
gap: 2px;
|
||||
padding: 5px;
|
||||
border: 1px solid #ffffff35;
|
||||
border-radius: 10px;
|
||||
background: #ffffff08;
|
||||
gap: 1px;
|
||||
padding: 3px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.neon-preview i[class*='neon-piece--'] {
|
||||
border: 1px solid #ffffff68;
|
||||
@@ -734,63 +728,49 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.neon-level {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 221px;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #ffffff0c;
|
||||
}
|
||||
.neon-level i {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(#73a7ff, #71f5e5);
|
||||
box-shadow: 0 0 9px #69f4e5;
|
||||
transition: height 0.25s;
|
||||
}
|
||||
.neon-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 5px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.neon-controls button {
|
||||
height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid #ffffff16;
|
||||
border-radius: 11px;
|
||||
color: #dffefd;
|
||||
background: #ffffff0b;
|
||||
}
|
||||
.neon-controls button:last-child {
|
||||
color: #071225;
|
||||
background: linear-gradient(135deg, #ffe265, #ff9167);
|
||||
}
|
||||
.neon-controls svg {
|
||||
transform: scale(1.2);
|
||||
transition: width 0.25s;
|
||||
}
|
||||
.neon-hint {
|
||||
margin: 7px 0 0;
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
right: 45px;
|
||||
bottom: 27px;
|
||||
left: 45px;
|
||||
margin: 0;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
color: #d6deed;
|
||||
font-size: 16px;
|
||||
background: #101a3891;
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.neon-overlay {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
inset: 42px 0 19px;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 25px;
|
||||
border-radius: 17px;
|
||||
background: #080c21e8;
|
||||
backdrop-filter: blur(6px);
|
||||
text-align: center;
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ChevronLeft,
|
||||
RotateCcw,
|
||||
Volume2,
|
||||
@@ -30,16 +26,6 @@ const canResume = computed(
|
||||
const currentHighest = computed(() =>
|
||||
Math.max(0, ...(numberMerge.game?.tiles.map((tile) => tile.value) ?? [])),
|
||||
)
|
||||
const directionButtons: Array<{
|
||||
direction: NumberMergeDirection
|
||||
icon: typeof ArrowUp
|
||||
}> = [
|
||||
{ direction: 'left', icon: ArrowLeft },
|
||||
{ direction: 'up', icon: ArrowUp },
|
||||
{ direction: 'down', icon: ArrowDown },
|
||||
{ direction: 'right', icon: ArrowRight },
|
||||
]
|
||||
|
||||
function formatScore(value: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(value)
|
||||
}
|
||||
@@ -139,8 +125,12 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="number-merge-app" :aria-label="phone.t('Apps.numberMerge.name')">
|
||||
<header class="number-merge-header">
|
||||
<main
|
||||
class="number-merge-app"
|
||||
:class="{ 'number-merge-app--playing': !numberMerge.menuOpen && game }"
|
||||
:aria-label="phone.t('Apps.numberMerge.name')"
|
||||
>
|
||||
<header v-if="numberMerge.menuOpen" class="number-merge-header">
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.numberMerge.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.numberMerge.name') }}</h1>
|
||||
@@ -317,17 +307,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
</div>
|
||||
|
||||
<p class="number-merge-hint">{{ phone.t('Apps.numberMerge.swipeHint') }}</p>
|
||||
<div class="number-merge-controls" :aria-label="phone.t('Apps.numberMerge.controls')">
|
||||
<button
|
||||
v-for="control in directionButtons"
|
||||
:key="control.direction"
|
||||
type="button"
|
||||
:aria-label="phone.t(`Apps.numberMerge.directions.${control.direction}`)"
|
||||
@click="move(control.direction)"
|
||||
>
|
||||
<component :is="control.icon" :size="19" :stroke-width="2.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="confirmNewGame" class="number-merge-confirm" role="dialog" aria-modal="true">
|
||||
@@ -360,6 +339,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.number-merge-app--playing {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.number-merge-header {
|
||||
height: 55px;
|
||||
display: flex;
|
||||
@@ -508,40 +491,73 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
}
|
||||
.number-merge-how-to small { display: block; color: #8b6559; font-size: 11px; line-height: 1.35; }
|
||||
|
||||
.number-merge-game { padding-top: 5px; }
|
||||
.number-merge-game {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 12% 88%, rgb(161 77 47 / 13%), transparent 32%),
|
||||
radial-gradient(circle at 88% 16%, rgb(255 246 219 / 42%), transparent 29%);
|
||||
}
|
||||
|
||||
.number-merge-toolbar {
|
||||
height: 48px;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 66px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr 1fr 36px;
|
||||
grid-template-columns: 32px 1fr 1fr 32px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid rgb(109 66 52 / 10%);
|
||||
border-radius: 21px;
|
||||
background: rgb(255 241 216 / 65%);
|
||||
box-shadow: 0 8px 24px rgb(95 48 31 / 13%);
|
||||
backdrop-filter: blur(14px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.number-merge-toolbar div {
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
grid-template-rows: 10px 20px;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.number-merge-toolbar strong { max-width: 80px; overflow: hidden; font-size: 18px; text-overflow: ellipsis; }
|
||||
.number-merge-toolbar span { line-height: 10px; }
|
||||
.number-merge-toolbar strong { display: block; max-width: 80px; overflow: hidden; font-size: 18px; line-height: 20px; text-overflow: ellipsis; }
|
||||
.number-merge-toolbar__restart { justify-self: end; }
|
||||
|
||||
.number-merge-toolbar .number-merge-toolbar__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.number-merge-board {
|
||||
--board-gap: 7px;
|
||||
--board-padding: 9px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 14px;
|
||||
left: 14px;
|
||||
width: auto;
|
||||
aspect-ratio: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--board-gap);
|
||||
padding: var(--board-padding);
|
||||
border: 1px solid rgb(78 41 32 / 9%);
|
||||
border: 0;
|
||||
border-radius: 21px;
|
||||
background: #80564c;
|
||||
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 17px 28px rgb(101 52 38 / 18%);
|
||||
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 14px 28px rgb(101 52 38 / 15%);
|
||||
transform: translateY(-47%);
|
||||
}
|
||||
|
||||
.number-merge-cell {
|
||||
@@ -638,25 +654,21 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.number-merge-hint { margin: 10px 0 7px; color: #976f61; font-size: 12px; text-align: center; }
|
||||
|
||||
.number-merge-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 38px);
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.number-merge-controls button {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid rgb(97 56 44 / 10%);
|
||||
border-radius: 12px;
|
||||
color: #71483b;
|
||||
background: rgb(255 255 255 / 38%);
|
||||
.number-merge-hint {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
right: 45px;
|
||||
bottom: 27px;
|
||||
left: 45px;
|
||||
margin: 0;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
color: #8e675b;
|
||||
background: rgb(255 241 216 / 52%);
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.number-merge-confirm {
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
kPreloader,
|
||||
kRange,
|
||||
kSearchbar,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
kToast,
|
||||
kToggle,
|
||||
} from 'konsta/vue'
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
import { PHONE_FRAME_COLORS } from '@/config/appearance'
|
||||
import { isLaunchablePhoneApp, PHONE_APPS } from '@/config/apps'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import PhonePasscode from '@/components/PhonePasscode.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import type {
|
||||
LaunchablePhoneAppDefinition,
|
||||
@@ -74,6 +77,7 @@ import {
|
||||
type SettingsView =
|
||||
| 'root'
|
||||
| 'account'
|
||||
| 'security'
|
||||
| 'notifications'
|
||||
| 'notification-detail'
|
||||
| 'sounds'
|
||||
@@ -82,6 +86,14 @@ type SettingsView =
|
||||
| 'wallpaper'
|
||||
type RootToggleKey = 'airplaneMode' | 'streamerMode'
|
||||
type SubmenuView = Exclude<SettingsView, 'root' | 'notification-detail'>
|
||||
type PasscodeFlow =
|
||||
| 'set-new'
|
||||
| 'set-confirm'
|
||||
| 'change-current'
|
||||
| 'change-new'
|
||||
| 'change-confirm'
|
||||
| 'disable'
|
||||
| null
|
||||
|
||||
const FACTORY_RESET_DURATION_MS = 60_000
|
||||
const FACTORY_RESET_CIRCUMFERENCE = 2 * Math.PI * 48
|
||||
@@ -110,6 +122,13 @@ const accountPassword = ref('')
|
||||
const accountConfirm = ref('')
|
||||
const accountSubmitting = ref(false)
|
||||
const accountToast = ref('')
|
||||
const passcodeBusy = ref(false)
|
||||
const passcodeCurrent = ref('')
|
||||
const passcodeError = ref('')
|
||||
const passcodeFirst = ref('')
|
||||
const passcodeFlow = ref<PasscodeFlow>(null)
|
||||
const passcodeLength = ref<4 | 6>(6)
|
||||
const passcodeResetKey = ref(0)
|
||||
const removeDeviceImei = ref('')
|
||||
const removeDevicePassword = ref('')
|
||||
const removeDeviceOpened = ref(false)
|
||||
@@ -152,6 +171,12 @@ const serviceRows = [
|
||||
},
|
||||
]
|
||||
const preferenceRows = [
|
||||
{
|
||||
key: 'security',
|
||||
view: 'security' as const,
|
||||
icon: KeyRound,
|
||||
iconColor: '#34c759',
|
||||
},
|
||||
{
|
||||
key: 'general',
|
||||
view: 'general' as const,
|
||||
@@ -204,6 +229,18 @@ const activeTitle = computed(() => {
|
||||
}
|
||||
return phone.t(`Apps.settings.${activeView.value}`)
|
||||
})
|
||||
const passcodeTitle = computed(() => {
|
||||
if (passcodeFlow.value === 'set-confirm') {
|
||||
return phone.t('Apps.settings.passcode.confirmNew')
|
||||
}
|
||||
if (passcodeFlow.value === 'change-current' || passcodeFlow.value === 'disable') {
|
||||
return phone.t('Apps.settings.passcode.enterCurrent')
|
||||
}
|
||||
if (passcodeFlow.value === 'change-confirm') {
|
||||
return phone.t('Apps.settings.passcode.confirmNew')
|
||||
}
|
||||
return phone.t('Apps.settings.passcode.enterNew')
|
||||
})
|
||||
|
||||
function matchesSearch(key: string): boolean {
|
||||
return (
|
||||
@@ -220,6 +257,9 @@ function updateSearch(event: Event): void {
|
||||
}
|
||||
|
||||
function openView(view: SubmenuView): void {
|
||||
if (view === 'security') {
|
||||
passcodeLength.value = phone.security.length ?? 6
|
||||
}
|
||||
activeView.value = view
|
||||
scrollPageToTop()
|
||||
}
|
||||
@@ -248,6 +288,115 @@ function toggleRootSetting(key: RootToggleKey): void {
|
||||
phone.setPreference(key, !phone.preferences.settings[key])
|
||||
}
|
||||
|
||||
function resetPasscodeInput(): void {
|
||||
passcodeError.value = ''
|
||||
passcodeResetKey.value += 1
|
||||
}
|
||||
|
||||
function beginSetPasscode(): void {
|
||||
passcodeLength.value = phone.security.length ?? passcodeLength.value
|
||||
passcodeFirst.value = ''
|
||||
passcodeCurrent.value = ''
|
||||
passcodeFlow.value = 'set-new'
|
||||
resetPasscodeInput()
|
||||
}
|
||||
|
||||
function beginChangePasscode(): void {
|
||||
passcodeFirst.value = ''
|
||||
passcodeCurrent.value = ''
|
||||
passcodeFlow.value = 'change-current'
|
||||
resetPasscodeInput()
|
||||
}
|
||||
|
||||
function beginDisablePasscode(): void {
|
||||
passcodeLength.value = phone.security.length ?? 6
|
||||
passcodeCurrent.value = ''
|
||||
passcodeFlow.value = 'disable'
|
||||
resetPasscodeInput()
|
||||
}
|
||||
|
||||
function cancelPasscodeFlow(): void {
|
||||
if (passcodeBusy.value) return
|
||||
passcodeFlow.value = null
|
||||
passcodeFirst.value = ''
|
||||
passcodeCurrent.value = ''
|
||||
resetPasscodeInput()
|
||||
}
|
||||
|
||||
function passcodeRequestError(error?: string): string {
|
||||
if (error === 'invalid_passcode') {
|
||||
return phone.t('Apps.settings.passcode.incorrect')
|
||||
}
|
||||
if (error === 'passcode_locked') {
|
||||
return phone.t('Apps.settings.passcode.locked')
|
||||
}
|
||||
if (error === 'rate_limited') {
|
||||
return phone.t('Apps.settings.passcode.rateLimited')
|
||||
}
|
||||
return phone.t('Apps.settings.passcode.failed')
|
||||
}
|
||||
|
||||
async function submitSettingsPasscode(passcode: string): Promise<void> {
|
||||
if (passcodeBusy.value || !passcodeFlow.value) return
|
||||
|
||||
if (passcodeFlow.value === 'set-new') {
|
||||
passcodeFirst.value = passcode
|
||||
passcodeFlow.value = 'set-confirm'
|
||||
resetPasscodeInput()
|
||||
return
|
||||
}
|
||||
if (passcodeFlow.value === 'change-current') {
|
||||
passcodeCurrent.value = passcode
|
||||
passcodeFlow.value = 'change-new'
|
||||
resetPasscodeInput()
|
||||
return
|
||||
}
|
||||
if (passcodeFlow.value === 'change-new') {
|
||||
passcodeFirst.value = passcode
|
||||
passcodeFlow.value = 'change-confirm'
|
||||
resetPasscodeInput()
|
||||
return
|
||||
}
|
||||
if (
|
||||
(passcodeFlow.value === 'set-confirm' ||
|
||||
passcodeFlow.value === 'change-confirm') &&
|
||||
passcode !== passcodeFirst.value
|
||||
) {
|
||||
passcodeError.value = phone.t('Apps.settings.passcode.mismatch')
|
||||
passcodeResetKey.value += 1
|
||||
return
|
||||
}
|
||||
|
||||
passcodeBusy.value = true
|
||||
const response =
|
||||
passcodeFlow.value === 'set-confirm'
|
||||
? await phone.setPasscode(passcode)
|
||||
: passcodeFlow.value === 'change-confirm'
|
||||
? await phone.changePasscode(passcodeCurrent.value, passcode)
|
||||
: await phone.disablePasscode(passcode)
|
||||
passcodeBusy.value = false
|
||||
if (!response.success) {
|
||||
passcodeError.value = passcodeRequestError(response.error)
|
||||
if (
|
||||
passcodeFlow.value === 'change-confirm' &&
|
||||
response.error === 'invalid_passcode'
|
||||
) {
|
||||
passcodeFlow.value = 'change-current'
|
||||
passcodeCurrent.value = ''
|
||||
passcodeFirst.value = ''
|
||||
}
|
||||
passcodeResetKey.value += 1
|
||||
return
|
||||
}
|
||||
|
||||
accountToast.value = phone.t(
|
||||
passcodeFlow.value === 'disable'
|
||||
? 'Apps.settings.passcode.disabled'
|
||||
: 'Apps.settings.passcode.saved',
|
||||
)
|
||||
cancelPasscodeFlow()
|
||||
}
|
||||
|
||||
function updateNumberPreference(
|
||||
key:
|
||||
| 'notificationDurationSeconds'
|
||||
@@ -551,6 +700,15 @@ onBeforeUnmount(() => {
|
||||
<component :is="row.icon" :size="17" :stroke-width="2.25" />
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="row.key === 'security'" #after>
|
||||
{{
|
||||
phone.t(
|
||||
phone.security.enabled
|
||||
? 'Apps.settings.on'
|
||||
: 'Apps.settings.off',
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
</template>
|
||||
@@ -715,6 +873,81 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeView === 'security'">
|
||||
<k-block class="text-sm leading-5 opacity-70">
|
||||
{{ phone.t('Apps.settings.passcode.description') }}
|
||||
</k-block>
|
||||
|
||||
<template v-if="!phone.security.enabled">
|
||||
<k-block-title>{{ phone.t('Apps.settings.passcode.codeLength') }}</k-block-title>
|
||||
<k-block>
|
||||
<k-segmented strong rounded>
|
||||
<k-segmented-button
|
||||
:active="passcodeLength === 6"
|
||||
@click="passcodeLength = 6"
|
||||
>
|
||||
{{ phone.t('Apps.settings.passcode.sixDigit') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
:active="passcodeLength === 4"
|
||||
@click="passcodeLength = 4"
|
||||
>
|
||||
{{ phone.t('Apps.settings.passcode.fourDigit') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</k-block>
|
||||
<k-list strong inset>
|
||||
<k-list-button @click="beginSetPasscode">
|
||||
{{ phone.t('Apps.settings.passcode.turnOn') }}
|
||||
</k-list-button>
|
||||
</k-list>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
:title="phone.t('Apps.settings.passcode.status')"
|
||||
:after="phone.t('Apps.settings.on')"
|
||||
/>
|
||||
<k-list-item
|
||||
:title="phone.t('Apps.settings.passcode.codeLength')"
|
||||
:after="
|
||||
phone.t(
|
||||
phone.security.length === 4
|
||||
? 'Apps.settings.passcode.fourDigit'
|
||||
: 'Apps.settings.passcode.sixDigit',
|
||||
)
|
||||
"
|
||||
/>
|
||||
</k-list>
|
||||
<k-block-title>{{ phone.t('Apps.settings.passcode.codeLength') }}</k-block-title>
|
||||
<k-block>
|
||||
<k-segmented strong rounded>
|
||||
<k-segmented-button
|
||||
:active="passcodeLength === 6"
|
||||
@click="passcodeLength = 6"
|
||||
>
|
||||
{{ phone.t('Apps.settings.passcode.sixDigit') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
:active="passcodeLength === 4"
|
||||
@click="passcodeLength = 4"
|
||||
>
|
||||
{{ phone.t('Apps.settings.passcode.fourDigit') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</k-block>
|
||||
<k-list strong inset>
|
||||
<k-list-button @click="beginChangePasscode">
|
||||
{{ phone.t('Apps.settings.passcode.change') }}
|
||||
</k-list-button>
|
||||
<k-list-button class="!text-red-500" @click="beginDisablePasscode">
|
||||
{{ phone.t('Apps.settings.passcode.turnOff') }}
|
||||
</k-list-button>
|
||||
</k-list>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeView === 'notifications'">
|
||||
<k-list strong inset>
|
||||
<k-list-item
|
||||
@@ -1102,6 +1335,18 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</k-page>
|
||||
|
||||
<PhonePasscode
|
||||
v-if="passcodeFlow"
|
||||
:busy="passcodeBusy"
|
||||
:error="passcodeError"
|
||||
:length="passcodeLength"
|
||||
:reset-key="passcodeResetKey"
|
||||
:subtitle="phone.t('Apps.settings.passcode.screenSubtitle')"
|
||||
:title="passcodeTitle"
|
||||
@cancel="cancelPasscodeFlow"
|
||||
@complete="submitSettingsPasscode"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="factoryResetting"
|
||||
class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-black px-8 text-center text-white"
|
||||
|
||||
@@ -106,8 +106,15 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flappy-app" :class="`flappy-app--${flappy.design}`" :aria-label="phone.t('Apps.skyFlappy.name')">
|
||||
<header class="flappy-header">
|
||||
<main
|
||||
class="flappy-app"
|
||||
:class="[
|
||||
`flappy-app--${flappy.design}`,
|
||||
{ 'flappy-app--playing': !flappy.menuOpen && game },
|
||||
]"
|
||||
:aria-label="phone.t('Apps.skyFlappy.name')"
|
||||
>
|
||||
<header v-if="flappy.menuOpen" class="flappy-header">
|
||||
<div><span>{{ phone.t('Apps.skyFlappy.eyebrow') }}</span><h1>{{ phone.t('Apps.skyFlappy.name') }}</h1></div>
|
||||
<button type="button" :aria-label="phone.t(flappy.soundEnabled ? 'Apps.skyFlappy.mute' : 'Apps.skyFlappy.unmute')" @click="toggleSound">
|
||||
<Volume2 v-if="flappy.soundEnabled" :size="18" /><VolumeX v-else :size="18" />
|
||||
@@ -160,10 +167,11 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none;touch-action:manipulation; }
|
||||
.flappy-app--playing { padding:0; }
|
||||
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
|
||||
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button,.flappy-toolbar button{width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}
|
||||
.flappy-menu{height:calc(100% - 55px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center}.flappy-menu__hero{position:relative;width:220px;height:210px;overflow:hidden;border-radius:28px;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 28px #223c6d35,0 18px 32px #101b3b66}.flappy-menu__hero::before{position:absolute;z-index:0;top:22px;left:25px;width:39px;height:39px;border-radius:50%;background:#ffe8a7cc;box-shadow:0 0 18px #fff1b46b;content:""}.flappy-menu__hero::after{position:absolute;z-index:1;bottom:30px;left:-12px;width:68px;height:18px;border-radius:999px;background:#ffffff3d;box-shadow:27px -9px 0 3px #ffffff36,57px 1px 0 -2px #ffffff2c;content:""}.flappy-menu__tower{position:absolute;z-index:3;right:10px;width:42px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 30%),var(--tower));box-shadow:inset 7px 0 0 #ffffff22,inset -6px 0 9px #0003}.flappy-menu__tower::after{position:absolute;left:-8px;width:58px;height:22px;border:3px solid color-mix(in srgb,var(--tower),black 22%);border-radius:7px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 36%),var(--tower));box-shadow:inset 0 4px 0 #ffffff26;content:""}.flappy-menu__tower--top{top:0;height:61px;border-radius:0 0 5px 5px}.flappy-menu__tower--top::after{bottom:-11px}.flappy-menu__tower--bottom{bottom:0;height:67px;border-radius:5px 5px 0 0}.flappy-menu__tower--bottom::after{top:-11px}.flappy-menu__trail{position:absolute;z-index:2;top:102px;left:11px;width:46px;height:4px;border-radius:99px;background:#edfbffb8;box-shadow:13px 12px 0 -1px #e9faff8c,6px -12px 0 -1px #e9faff73;animation:flappy-trail 1s ease-in-out infinite}.sky-bird{position:absolute;z-index:4;width:66px;height:46px;overflow:visible;filter:drop-shadow(0 4px 5px #071d3e66)}.sky-bird :deep(path){stroke-linecap:round;stroke-linejoin:round}.sky-bird :deep(.sky-flappy-bird__far-wing){fill:#2d7188;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__far-wing path:last-child){fill:none;stroke:#8ec6ce;stroke-width:1.4;opacity:.65}.sky-bird :deep(.sky-flappy-bird__tail){fill:#285e78;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__body){fill:url(#sky-bird-body);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__belly){fill:#b7e5df;opacity:.84}.sky-bird :deep(.sky-flappy-bird__neck){fill:#377f98}.sky-bird :deep(.sky-flappy-bird__wing){transform-box:view-box;transform-origin:52px 38px}.sky-bird :deep(.sky-flappy-bird__wing-shape){fill:url(#sky-bird-wing);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__feather){fill:none;stroke:#d4f0ed;stroke-width:1.5;opacity:.78}.sky-bird :deep(.sky-flappy-bird__face){fill:#d8f0e9}.sky-bird :deep(.sky-flappy-bird__eye-ring){fill:#f5fbf7}.sky-bird :deep(.sky-flappy-bird__eye){fill:#102d3d}.sky-bird :deep(.sky-flappy-bird__beak){fill:#f2a84b;stroke:#6f4727;stroke-width:1.5}.sky-bird--hero{top:72px;left:42px;width:100px;height:69px;animation:flappy-hero 1.1s ease-in-out infinite alternate}.sky-bird--hero :deep(.sky-flappy-bird__wing){animation:flappy-bird-glide 1.1s ease-in-out infinite}.sky-bird--hero :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-glide 1.1s ease-in-out infinite}.flappy-menu__copy>span{color:#ffda70;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-menu__copy h2{margin:3px 0 5px;font-size:21px}.flappy-menu__copy p{max-width:275px;margin:0;color:#c0c8df;font-size:10px;line-height:1.4}.flappy-record{width:100%;display:flex;align-items:center;justify-content:space-between;padding:9px 14px;border:1px solid #ffffff14;border-radius:13px;background:#ffffff0c}.flappy-record span{color:#bdc8df;font-size:9px;font-weight:800;text-transform:uppercase}.flappy-record strong{font-size:19px}.flappy-designs{width:100%;display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.flappy-designs button{display:grid;place-items:center;gap:3px;padding:7px 3px;border:1px solid #ffffff12;border-radius:12px;color:#ccd4e9;background:#ffffff0a;font-size:8px}.flappy-designs button.active{border-color:#ffdd72;color:#fff;background:#ffffff1b}.flappy-designs i{width:26px;height:13px;border-radius:8px}.flappy-designs__dawn{background:linear-gradient(90deg,#58d6ee,#ff9b80)}.flappy-designs__neon{background:linear-gradient(90deg,#24255f,#ef55ca)}.flappy-designs__storm{background:linear-gradient(90deg,#71899b,#253750)}.flappy-primary,.flappy-secondary{width:100%;min-height:43px;display:flex;align-items:center;justify-content:center;gap:7px;border-radius:14px;font-size:11px;font-weight:850}.flappy-primary{border:0;color:#173353;background:linear-gradient(135deg,#ffe16c,#ff9d68)}.flappy-secondary{border:1px solid #ffffff18;color:#fff;background:#ffffff0b}.flappy-menu>p,.flappy-game__hint{margin:0;color:#aeb9d2;font-size:9px}
|
||||
.flappy-game{position:relative;height:calc(100% - 55px)}.flappy-toolbar{height:47px;display:grid;grid-template-columns:36px 1fr 1fr 36px;align-items:center;gap:7px}.flappy-toolbar div{display:grid;justify-items:center}.flappy-toolbar span{color:#bac9df;font-size:8px;font-weight:850;text-transform:uppercase}.flappy-toolbar strong{font-size:16px}.flappy-stage{position:relative;width:100%;height:500px;display:block;overflow:hidden;padding:0;border:1px solid #ffffff1c;border-radius:22px;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 35px #15244c55,0 16px 30px #10193477;touch-action:manipulation}.flappy-clouds{position:absolute;z-index:1;inset:0;overflow:hidden;pointer-events:none}.flappy-clouds i{--cloud-scale:1;--cloud-opacity:.34;--cloud-duration:18s;--cloud-delay:0s;position:absolute;left:100%;width:70px;height:18px;border-radius:999px;background:linear-gradient(180deg,#ffffffd9,#eaf7ff9c);box-shadow:0 8px 16px #24376518;opacity:var(--cloud-opacity);animation:cloud-drift var(--cloud-duration) linear var(--cloud-delay) infinite;will-change:transform}.flappy-clouds i::before{position:absolute;bottom:4px;left:12px;width:29px;height:29px;border-radius:50%;background:#f8fcffe6;box-shadow:22px -8px 0 4px #f7fcff,40px 1px 0 -2px #eef9ff;content:""}.flappy-clouds i::after{position:absolute;right:8px;bottom:-3px;left:8px;height:8px;border-radius:50%;background:#bcdff477;filter:blur(4px);content:""}.flappy-clouds i:nth-child(1){--cloud-scale:.7;--cloud-opacity:.3;--cloud-duration:20s;--cloud-delay:-4s;top:9%}.flappy-clouds i:nth-child(2){--cloud-scale:1.05;--cloud-opacity:.4;--cloud-duration:15s;--cloud-delay:-11s;top:22%}.flappy-clouds i:nth-child(3){--cloud-scale:.52;--cloud-opacity:.25;--cloud-duration:23s;--cloud-delay:-17s;top:38%}.flappy-clouds i:nth-child(4){--cloud-scale:.88;--cloud-opacity:.36;--cloud-duration:17s;--cloud-delay:-7s;top:53%}.flappy-clouds i:nth-child(5){--cloud-scale:1.18;--cloud-opacity:.42;--cloud-duration:14s;--cloud-delay:-2s;top:68%}.flappy-clouds i:nth-child(6){--cloud-scale:.62;--cloud-opacity:.27;--cloud-duration:21s;--cloud-delay:-14s;top:78%}.flappy-clouds i:nth-child(7){--cloud-scale:.96;--cloud-opacity:.34;--cloud-duration:16s;--cloud-delay:-9s;top:86%}.flappy-obstacle{position:absolute;z-index:2;top:0;bottom:0}.flappy-obstacle span{position:absolute;right:0;left:0;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 20%),var(--tower));box-shadow:inset -6px 0 8px #0003,0 0 13px #17204e55}.flappy-obstacle span::after{position:absolute;right:-4px;left:-4px;height:14px;border-radius:6px;background:color-mix(in srgb,var(--tower),white 10%);box-shadow:inset 0 3px 0 #ffffff25;content:""}.flappy-obstacle__top{top:0;border-radius:0 0 7px 7px}.flappy-obstacle__top::after{bottom:0}.flappy-obstacle__bottom{bottom:0;border-radius:7px 7px 0 0}.flappy-obstacle__bottom::after{top:0}.sky-bird--player{left:23%;animation:flappy-wing .24s ease-out}.sky-bird--player :deep(.sky-flappy-bird__wing){animation:flappy-bird-flap .24s cubic-bezier(.2,.75,.35,1)}.sky-bird--player :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-flap .24s cubic-bezier(.2,.75,.35,1)}.flappy-ready{position:absolute;z-index:6;top:36%;left:50%;padding:9px 15px;border-radius:17px;background:#15284fbb;font-size:11px;transform:translateX(-50%)}.flappy-horizon{position:absolute;z-index:3;right:0;bottom:0;left:0;height:12px;background:#263a62;box-shadow:0 -5px 14px #ffffff26}.flappy-stage--crashed{animation:flappy-crash .55s ease-out}.flappy-game__hint{margin-top:8px;text-align:center}.flappy-overlay{position:absolute;z-index:12;inset:47px 0 25px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:28px;border-radius:22px;background:#101a38dc;backdrop-filter:blur(7px);text-align:center}.flappy-overlay>svg{color:#ffdc71}.flappy-overlay>span{color:#ffad78;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-overlay h2{margin:0 0 5px;font-size:26px}
|
||||
.flappy-game{position:absolute;inset:0}.flappy-toolbar{position:absolute;z-index:10;top:48px;right:14px;left:14px;height:42px;display:grid;grid-template-columns:36px 1fr 1fr 36px;align-items:center;gap:7px;padding:4px 6px;border:1px solid #ffffff2b;border-radius:22px;background:#263c6da8;box-shadow:0 8px 24px #10193455;backdrop-filter:blur(14px)}.flappy-toolbar div{display:grid;justify-items:center}.flappy-toolbar span{color:#bac9df;font-size:8px;font-weight:850;text-transform:uppercase}.flappy-toolbar strong{font-size:16px}.flappy-toolbar button{width:34px;height:34px;border:0;border-radius:50%;box-shadow:none}.flappy-stage{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:hidden;padding:0;border:0;border-radius:0;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 35px #15244c55;touch-action:manipulation}.flappy-clouds{position:absolute;z-index:1;inset:0;overflow:hidden;pointer-events:none}.flappy-clouds i{--cloud-scale:1;--cloud-opacity:.34;--cloud-duration:18s;--cloud-delay:0s;position:absolute;left:100%;width:70px;height:18px;border-radius:999px;background:linear-gradient(180deg,#ffffffd9,#eaf7ff9c);box-shadow:0 8px 16px #24376518;opacity:var(--cloud-opacity);animation:cloud-drift var(--cloud-duration) linear var(--cloud-delay) infinite;will-change:transform}.flappy-clouds i::before{position:absolute;bottom:4px;left:12px;width:29px;height:29px;border-radius:50%;background:#f8fcffe6;box-shadow:22px -8px 0 4px #f7fcff,40px 1px 0 -2px #eef9ff;content:""}.flappy-clouds i::after{position:absolute;right:8px;bottom:-3px;left:8px;height:8px;border-radius:50%;background:#bcdff477;filter:blur(4px);content:""}.flappy-clouds i:nth-child(1){--cloud-scale:.7;--cloud-opacity:.3;--cloud-duration:20s;--cloud-delay:-4s;top:9%}.flappy-clouds i:nth-child(2){--cloud-scale:1.05;--cloud-opacity:.4;--cloud-duration:15s;--cloud-delay:-11s;top:22%}.flappy-clouds i:nth-child(3){--cloud-scale:.52;--cloud-opacity:.25;--cloud-duration:23s;--cloud-delay:-17s;top:38%}.flappy-clouds i:nth-child(4){--cloud-scale:.88;--cloud-opacity:.36;--cloud-duration:17s;--cloud-delay:-7s;top:53%}.flappy-clouds i:nth-child(5){--cloud-scale:1.18;--cloud-opacity:.42;--cloud-duration:14s;--cloud-delay:-2s;top:68%}.flappy-clouds i:nth-child(6){--cloud-scale:.62;--cloud-opacity:.27;--cloud-duration:21s;--cloud-delay:-14s;top:78%}.flappy-clouds i:nth-child(7){--cloud-scale:.96;--cloud-opacity:.34;--cloud-duration:16s;--cloud-delay:-9s;top:86%}.flappy-obstacle{position:absolute;z-index:2;top:0;bottom:0}.flappy-obstacle span{position:absolute;right:0;left:0;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 20%),var(--tower));box-shadow:inset -6px 0 8px #0003,0 0 13px #17204e55}.flappy-obstacle span::after{position:absolute;right:-4px;left:-4px;height:14px;border-radius:6px;background:color-mix(in srgb,var(--tower),white 10%);box-shadow:inset 0 3px 0 #ffffff25;content:""}.flappy-obstacle__top{top:0;border-radius:0 0 7px 7px}.flappy-obstacle__top::after{bottom:0}.flappy-obstacle__bottom{bottom:0;border-radius:7px 7px 0 0}.flappy-obstacle__bottom::after{top:0}.sky-bird--player{left:23%;animation:flappy-wing .24s ease-out}.sky-bird--player :deep(.sky-flappy-bird__wing){animation:flappy-bird-flap .24s cubic-bezier(.2,.75,.35,1)}.sky-bird--player :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-flap .24s cubic-bezier(.2,.75,.35,1)}.flappy-ready{position:absolute;z-index:6;top:36%;left:50%;padding:9px 15px;border-radius:17px;background:#15284fbb;font-size:11px;transform:translateX(-50%)}.flappy-horizon{position:absolute;z-index:3;right:0;bottom:0;left:0;height:12px;background:#263a62;box-shadow:0 -5px 14px #ffffff26}.flappy-stage--crashed{animation:flappy-crash .55s ease-out}.flappy-game__hint{position:absolute;z-index:6;right:45px;bottom:27px;left:45px;margin:0;padding:7px 10px;border-radius:999px;background:#263c6d91;backdrop-filter:blur(10px);text-align:center;pointer-events:none}.flappy-overlay{position:absolute;z-index:12;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:28px;background:#101a38dc;backdrop-filter:blur(7px);text-align:center}.flappy-overlay>svg{color:#ffdc71}.flappy-overlay>span{color:#ffad78;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-overlay h2{margin:0 0 5px;font-size:26px}
|
||||
.flappy-menu__tower{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff38,inset -6px 0 9px #0004,0 0 18px var(--tower-glow)}
|
||||
.flappy-menu__tower::after{border-color:var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower));box-shadow:inset 0 4px 0 #ffffff45,0 0 14px var(--tower-glow)}
|
||||
.flappy-menu__copy>span{color:#ffdf7d;font-size:14px}
|
||||
@@ -178,9 +186,13 @@ onBeforeUnmount(() => {
|
||||
.flappy-primary,.flappy-secondary{min-height:50px;gap:8px;font-size:18px}
|
||||
.flappy-secondary{border-color:#ffffff2c;background:#ffffff12}
|
||||
.flappy-menu>p,.flappy-game__hint{color:#e1e9f6;font-size:16px;font-weight:700;line-height:1.35}
|
||||
.flappy-toolbar span{color:#e0eafa;font-size:12px;letter-spacing:.35px}
|
||||
.flappy-toolbar strong{font-size:21px}
|
||||
.flappy-stage{border:2px solid #ffffff70;box-shadow:inset 0 0 35px #15244c38,0 16px 30px #10193477}
|
||||
.flappy-toolbar span{color:#e0eafa;font-size:11px;line-height:10px;letter-spacing:.35px}
|
||||
.flappy-toolbar strong{display:block;font-size:19px;line-height:20px}
|
||||
.flappy-toolbar{top:66px;right:18px;left:18px;height:42px;grid-template-columns:32px 1fr 1fr 32px;gap:4px;padding:4px;border-radius:21px;box-sizing:border-box}
|
||||
.flappy-toolbar div{height:32px;display:grid;grid-template-rows:10px 20px;align-content:center;justify-items:center}
|
||||
.flappy-toolbar button{width:32px;height:32px}
|
||||
.flappy-clouds{top:110px}
|
||||
.flappy-stage{border:0;box-shadow:inset 0 0 35px #15244c38}
|
||||
.flappy-obstacle span{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff42,inset -6px 0 8px #0004,0 0 18px var(--tower-glow)}
|
||||
.flappy-obstacle span::after{right:-5px;left:-5px;height:17px;border:2px solid var(--tower-dark);background:linear-gradient(180deg,var(--tower-light),var(--tower));box-shadow:inset 0 4px 0 #ffffff55,0 0 14px var(--tower-glow)}
|
||||
.flappy-ready{padding:11px 18px;border:2px solid #ffffff55;border-radius:19px;background:#15284fe6;font-size:16px;font-weight:850}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Pause,
|
||||
Play,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
|
||||
import {
|
||||
SNAKE_BOARD_HEIGHT,
|
||||
@@ -23,17 +20,7 @@ import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const snake = useSnakeStore()
|
||||
const touchStart = ref<SnakePoint | null>(null)
|
||||
const speedOptions: SnakeSpeed[] = ['relaxed', 'normal', 'fast']
|
||||
const directionButtons: Array<{
|
||||
direction: SnakeDirection
|
||||
icon: typeof ChevronUp
|
||||
}> = [
|
||||
{ direction: 'up', icon: ChevronUp },
|
||||
{ direction: 'left', icon: ChevronLeft },
|
||||
{ direction: 'down', icon: ChevronDown },
|
||||
{ direction: 'right', icon: ChevronRight },
|
||||
]
|
||||
const game = computed(() => snake.game)
|
||||
const boardMotionStyle = computed(() => ({
|
||||
'--snake-motion-duration': `${Math.min(
|
||||
@@ -90,7 +77,9 @@ function stopGameTimer(): void {
|
||||
function syncGameTimer(): void {
|
||||
stopGameTimer()
|
||||
if (snake.game?.status === 'playing') {
|
||||
gameTimer = setInterval(() => snake.tick(), snake.tickMilliseconds)
|
||||
gameTimer = setInterval(() => {
|
||||
snake.tick()
|
||||
}, snake.tickMilliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,16 +88,16 @@ function returnToMenu(): void {
|
||||
snake.showMenu()
|
||||
}
|
||||
|
||||
function startGame(): void {
|
||||
snake.start()
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
const directionByKey: Partial<Record<string, SnakeDirection>> = {
|
||||
ArrowDown: 'down',
|
||||
ArrowLeft: 'left',
|
||||
ArrowRight: 'right',
|
||||
ArrowUp: 'up',
|
||||
a: 'left',
|
||||
d: 'right',
|
||||
s: 'down',
|
||||
w: 'up',
|
||||
}
|
||||
const direction = directionByKey[event.key]
|
||||
|
||||
@@ -125,28 +114,6 @@ function handleKeydown(event: KeyboardEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
function beginSwipe(event: TouchEvent): void {
|
||||
const touch = event.changedTouches[0]
|
||||
touchStart.value = touch ? { x: touch.clientX, y: touch.clientY } : null
|
||||
}
|
||||
|
||||
function endSwipe(event: TouchEvent): void {
|
||||
const start = touchStart.value
|
||||
const touch = event.changedTouches[0]
|
||||
touchStart.value = null
|
||||
if (!start || !touch) return
|
||||
|
||||
const deltaX = touch.clientX - start.x
|
||||
const deltaY = touch.clientY - start.y
|
||||
if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 18) return
|
||||
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
snake.turn(deltaX > 0 ? 'right' : 'left')
|
||||
} else {
|
||||
snake.turn(deltaY > 0 ? 'down' : 'up')
|
||||
}
|
||||
}
|
||||
|
||||
snake.hydrate()
|
||||
watch(
|
||||
() => [snake.game?.status, snake.tickMilliseconds],
|
||||
@@ -162,8 +129,12 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="snake-app" :aria-label="phone.t('Apps.snake.name')">
|
||||
<header class="snake-header">
|
||||
<main
|
||||
class="snake-app"
|
||||
:class="{ 'snake-app--playing': game }"
|
||||
:aria-label="phone.t('Apps.snake.name')"
|
||||
>
|
||||
<header v-if="!game" class="snake-header">
|
||||
<span class="snake-brand">{{ phone.t('Apps.snake.name') }}</span>
|
||||
<div class="snake-score-card">
|
||||
<span>{{ phone.t('Apps.snake.highScore') }}</span>
|
||||
@@ -226,7 +197,7 @@ onBeforeUnmount(() => {
|
||||
{{ phone.t(`Apps.snake.speeds.${speed}`) }}
|
||||
</button>
|
||||
</fieldset>
|
||||
<button type="button" class="snake-primary" @click="snake.start">
|
||||
<button type="button" class="snake-primary" @click="startGame">
|
||||
<Play :size="18" fill="currentColor" />
|
||||
{{ phone.t('Apps.snake.start') }}
|
||||
</button>
|
||||
@@ -243,8 +214,10 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<ChevronLeft :size="18" :stroke-width="2.7" aria-hidden="true" />
|
||||
</button>
|
||||
<span>{{ phone.t('Apps.snake.score') }}</span>
|
||||
<strong>{{ game.score }}</strong>
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.snake.score') }}</span>
|
||||
<strong>{{ game.score }}</strong>
|
||||
</div>
|
||||
<button
|
||||
v-if="game.status !== 'game-over'"
|
||||
type="button"
|
||||
@@ -267,8 +240,6 @@ onBeforeUnmount(() => {
|
||||
class="snake-board"
|
||||
:style="boardMotionStyle"
|
||||
:aria-label="phone.t('Apps.snake.board')"
|
||||
@touchstart.passive="beginSwipe"
|
||||
@touchend.passive="endSwipe"
|
||||
>
|
||||
<span
|
||||
v-for="(_, index) in game.body.slice(1)"
|
||||
@@ -306,7 +277,7 @@ onBeforeUnmount(() => {
|
||||
<template v-else>
|
||||
<span class="snake-overline">{{ phone.t('Apps.snake.score') }} {{ game.score }}</span>
|
||||
<h2>{{ phone.t('Apps.snake.gameOver') }}</h2>
|
||||
<button type="button" class="snake-primary" @click="snake.start">
|
||||
<button type="button" class="snake-primary" @click="startGame">
|
||||
{{ phone.t('Apps.snake.restart') }}
|
||||
</button>
|
||||
<button type="button" class="snake-secondary" @click="returnToMenu">
|
||||
@@ -317,18 +288,6 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<p class="snake-hint">{{ phone.t('Apps.snake.swipeHint') }}</p>
|
||||
<div class="snake-controls" :aria-label="phone.t('Apps.snake.controls')">
|
||||
<button
|
||||
v-for="control in directionButtons"
|
||||
:key="control.direction"
|
||||
type="button"
|
||||
:class="`snake-control--${control.direction}`"
|
||||
:aria-label="phone.t(`Apps.snake.directions.${control.direction}`)"
|
||||
@click="snake.turn(control.direction)"
|
||||
>
|
||||
<component :is="control.icon" :size="24" :stroke-width="2.6" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -348,6 +307,10 @@ onBeforeUnmount(() => {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.snake-app--playing {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.snake-header,
|
||||
.snake-game__meta {
|
||||
display: flex;
|
||||
@@ -507,53 +470,89 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.snake-game {
|
||||
padding-top: 8px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.snake-game__meta {
|
||||
height: 43px;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
top: 66px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 32px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border-radius: 21px;
|
||||
background: rgb(8 22 18 / 58%);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
|
||||
backdrop-filter: blur(14px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.snake-game__meta div {
|
||||
height: 32px;
|
||||
display: grid;
|
||||
grid-template-rows: 10px 20px;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.snake-game__meta span {
|
||||
color: #9fb3a9;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.snake-game__meta strong {
|
||||
font-size: 22px;
|
||||
display: block;
|
||||
font-size: 19px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.snake-game__meta button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #dff6d9;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
background: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
.snake-game__meta .snake-game__pause { margin-left: auto; }
|
||||
.snake-game__meta .snake-game__back { flex: 0 0 auto; }
|
||||
.snake-game__meta .snake-game__pause { justify-self: end; }
|
||||
.snake-game__meta .snake-game__back { justify-self: start; }
|
||||
|
||||
.snake-board {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 18;
|
||||
position: absolute;
|
||||
top: 118px;
|
||||
right: 18px;
|
||||
bottom: 62px;
|
||||
left: 18px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(167 231 157 / 16%);
|
||||
border: 1px solid rgb(145 228 117 / 24%);
|
||||
border-radius: 18px;
|
||||
background-color: #162d26;
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 2%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 2%) 1px, transparent 1px);
|
||||
background-size: calc(100% / 16) calc(100% / 18);
|
||||
box-shadow: inset 0 0 35px rgb(0 0 0 / 20%), 0 17px 35px rgb(0 0 0 / 20%);
|
||||
linear-gradient(rgb(173 233 160 / 4%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(173 233 160 / 4%) 1px, transparent 1px),
|
||||
radial-gradient(circle at 50% 42%, rgb(94 191 91 / 8%), transparent 68%);
|
||||
background-size:
|
||||
calc(100% / 16) calc(100% / 30),
|
||||
calc(100% / 16) calc(100% / 30),
|
||||
100% 100%;
|
||||
box-shadow:
|
||||
inset 0 0 55px rgb(0 0 0 / 24%),
|
||||
inset 0 0 0 1px rgb(213 255 199 / 3%),
|
||||
0 12px 30px rgb(0 0 0 / 20%),
|
||||
0 0 18px rgb(89 196 82 / 7%);
|
||||
}
|
||||
|
||||
.snake-head,
|
||||
@@ -679,36 +678,22 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.snake-hint {
|
||||
margin: 9px 0 6px;
|
||||
color: #71867c;
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
right: 50px;
|
||||
bottom: 27px;
|
||||
left: 50px;
|
||||
margin: 0;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
color: #9eb2a8;
|
||||
background: rgb(8 22 18 / 48%);
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.snake-controls {
|
||||
position: relative;
|
||||
width: 132px;
|
||||
height: 91px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.snake-controls button {
|
||||
position: absolute;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border-radius: 13px;
|
||||
color: #cae6d1;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.snake-control--up { left: 45px; top: 0; }
|
||||
.snake-control--left { left: 0; top: 45px; }
|
||||
.snake-control--down { left: 45px; top: 45px; }
|
||||
.snake-control--right { right: 0; top: 45px; }
|
||||
|
||||
button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { usePhoneStore } from '@/stores/phone'
|
||||
const phone = usePhoneStore()
|
||||
const tower = useTowerStackStore()
|
||||
const game = computed(() => tower.game)
|
||||
const visibleBlocks = computed(() => game.value?.blocks.slice(-9) ?? [])
|
||||
const visibleBlocks = computed(() => game.value?.blocks.slice(-6) ?? [])
|
||||
const placementEffect = ref<'missed' | 'perfect' | 'placed' | null>(null)
|
||||
const fallingBlock = ref<TowerActiveBlock | null>(null)
|
||||
const fallingStyle = ref<CSSProperties>({})
|
||||
@@ -175,8 +175,12 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="tower-app" :aria-label="phone.t('Apps.towerStack.name')">
|
||||
<header class="tower-header">
|
||||
<main
|
||||
class="tower-app"
|
||||
:class="{ 'tower-app--playing': !tower.menuOpen && game }"
|
||||
:aria-label="phone.t('Apps.towerStack.name')"
|
||||
>
|
||||
<header v-if="tower.menuOpen" class="tower-header">
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.towerStack.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.towerStack.name') }}</h1>
|
||||
@@ -214,7 +218,13 @@ onBeforeUnmount(() => {
|
||||
<Play :size="17" fill="currentColor" />
|
||||
{{ phone.t('Apps.towerStack.resume') }}
|
||||
</button>
|
||||
<button type="button" class="tower-secondary" @click="startGame">
|
||||
<button
|
||||
type="button"
|
||||
:class="game?.status === 'paused' ? 'tower-secondary' : 'tower-primary'"
|
||||
@click="startGame"
|
||||
>
|
||||
<RotateCcw v-if="game" :size="16" aria-hidden="true" />
|
||||
<Play v-else :size="16" fill="currentColor" aria-hidden="true" />
|
||||
{{ phone.t(game ? 'Apps.towerStack.newGame' : 'Apps.towerStack.start') }}
|
||||
</button>
|
||||
<p class="tower-menu__hint">{{ phone.t('Apps.towerStack.tapHint') }}</p>
|
||||
@@ -305,32 +315,34 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; touch-action: manipulation; }
|
||||
.tower-header { height: 55px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.tower-header span { display: block; color: #c1b8f1; font-size: 14px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
|
||||
.tower-header h1 { margin: 1px 0 0; font-size: 32px; line-height: 1; letter-spacing: -0.8px; }
|
||||
.tower-app--playing { padding: 0; }
|
||||
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
|
||||
.tower-header h1 { margin: 1px 0 0; font-size: 27px; line-height: 1; letter-spacing: -0.8px; }
|
||||
.tower-header button, .tower-toolbar button { width: 36px; height: 36px; display: grid; place-items: center; padding: 0; border: 1px solid #ffffff14; border-radius: 12px; color: #f2edff; background: #ffffff0d; }
|
||||
.tower-menu { height: calc(100% - 55px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; text-align: center; }
|
||||
.tower-menu__preview { position: relative; width: 190px; height: 210px; }
|
||||
.tower-menu__preview i { position: absolute; right: 12px; bottom: calc(var(--preview-index) * 25px); left: 25px; height: 29px; border-radius: 7px; background: hsl(calc(var(--preview-index) * 49deg + 5deg) 82% 62%); box-shadow: inset 0 4px 0 #ffffff35, 0 8px 15px #08091c5c; transform: perspective(200px) rotateX(5deg); }
|
||||
.tower-menu__preview i:nth-child(even) { right: 25px; left: 12px; }
|
||||
.tower-menu__preview span { position: absolute; top: 4px; left: 2px; width: 120px; height: 29px; border-radius: 7px; background: #ff6b68; box-shadow: 0 0 24px #ff6b6877; animation: tower-preview-slide 1.5s ease-in-out infinite alternate; }
|
||||
.tower-menu__copy > span { color: #ffcf59; font-size: 14px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.tower-menu__copy h2 { margin: 4px 0 7px; font-size: 30px; }
|
||||
.tower-menu__copy p { max-width: 295px; margin: 0; color: #e0dcf2; font-size: 18px; font-weight: 550; line-height: 1.4; }
|
||||
.tower-menu { height: calc(100% - 50px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; text-align: center; }
|
||||
.tower-menu__preview { position: relative; flex: 0 0 124px; width: 158px; height: 124px; }
|
||||
.tower-menu__preview i { position: absolute; right: 12px; bottom: calc((var(--preview-index) - 1) * 15px); left: 23px; height: 21px; border-radius: 6px; background: hsl(calc(var(--preview-index) * 49deg + 5deg) 82% 62%); box-shadow: inset 0 3px 0 #ffffff35, 0 5px 11px #08091c5c; transform: perspective(200px) rotateX(5deg); }
|
||||
.tower-menu__preview i:nth-child(even) { right: 23px; left: 12px; }
|
||||
.tower-menu__preview span { position: absolute; top: 0; left: 4px; width: 100px; height: 21px; border-radius: 6px; background: #ff6b68; box-shadow: 0 0 20px #ff6b6877; animation: tower-preview-slide 1.5s ease-in-out infinite alternate; }
|
||||
.tower-menu__copy > span { color: #ffcf59; font-size: 10px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.tower-menu__copy h2 { max-width: 270px; margin: 3px auto 5px; font-size: 24px; line-height: 1.08; }
|
||||
.tower-menu__copy p { max-width: 285px; margin: 0; color: #d8d3eb; font-size: 12px; font-weight: 550; line-height: 1.35; }
|
||||
.tower-records { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.tower-records div { display: grid; gap: 3px; padding: 12px 9px; border: 1px solid #ffffff1f; border-radius: 13px; background: #ffffff0d; }
|
||||
.tower-records span { color: #d1caea; font-size: 15px; font-weight: 850; text-transform: uppercase; }
|
||||
.tower-records strong { font-size: 26px; }
|
||||
.tower-primary, .tower-secondary { width: 100%; min-height: 50px; display: flex; align-items: center; justify-content: center; gap: 8px; border-radius: 14px; font-size: 18px; font-weight: 850; }
|
||||
.tower-records div { display: grid; gap: 1px; padding: 8px 9px; border: 1px solid #ffffff1f; border-radius: 13px; background: #ffffff0d; backdrop-filter: blur(8px); }
|
||||
.tower-records span { color: #d1caea; font-size: 9px; font-weight: 850; letter-spacing: .35px; text-transform: uppercase; }
|
||||
.tower-records strong { font-size: 20px; }
|
||||
.tower-primary, .tower-secondary { width: 100%; min-height: 42px; display: flex; align-items: center; justify-content: center; gap: 7px; border-radius: 14px; font-size: 13px; font-weight: 850; }
|
||||
.tower-primary { border: 0; color: #1e1839; background: linear-gradient(135deg, #ffca4f, #ff8760); box-shadow: 0 8px 18px #ff895330; }
|
||||
.tower-secondary { border: 1px solid #ffffff16; color: #eeeaff; background: #ffffff0a; }
|
||||
.tower-menu__hint, .tower-game__hint { margin: 0; color: #e0daf0; font-size: 16px; font-weight: 700; line-height: 1.35; }
|
||||
.tower-game { position: relative; height: calc(100% - 55px); }
|
||||
.tower-toolbar { height: 47px; display: grid; grid-template-columns: 36px 1fr 1fr 36px; align-items: center; gap: 7px; }
|
||||
.tower-toolbar div { display: grid; justify-items: center; line-height: 1.05; }
|
||||
.tower-toolbar span { color: #c9c2e9; font-size: 12px; font-weight: 850; letter-spacing: .35px; text-transform: uppercase; }
|
||||
.tower-toolbar strong { font-size: 21px; }
|
||||
.tower-stage { position: relative; width: 100%; height: 500px; display: block; overflow: hidden; padding: 0; border: 1px solid #ffffff35; border-radius: 22px; background: linear-gradient(#1d1b52, #433078 60%, #8e4c78); box-shadow: inset 0 0 35px #08091d80, 0 16px 28px #08091c66; touch-action: manipulation; }
|
||||
.tower-menu__hint, .tower-game__hint { margin: 0; color: #c9c2e0; font-size: 10px; font-weight: 700; line-height: 1.35; }
|
||||
.tower-game { position: absolute; inset: 0; }
|
||||
.tower-toolbar { position: absolute; z-index: 10; top: 66px; right: 18px; left: 18px; height: 42px; display: grid; grid-template-columns: 32px 1fr 1fr 32px; align-items: center; gap: 4px; padding: 4px; border: 1px solid #ffffff1a; border-radius: 21px; background: #292451a8; box-shadow: 0 8px 24px #08091c52; backdrop-filter: blur(14px); box-sizing: border-box; }
|
||||
.tower-toolbar div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
|
||||
.tower-toolbar span { color: #c9c2e9; font-size: 11px; font-weight: 850; line-height: 10px; letter-spacing: .35px; text-transform: uppercase; }
|
||||
.tower-toolbar strong { display: block; font-size: 19px; line-height: 20px; }
|
||||
.tower-toolbar button { width: 32px; height: 32px; border: 0; border-radius: 50%; box-shadow: none; }
|
||||
.tower-stage { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: hidden; padding: 0; border: 0; border-radius: 0; background: linear-gradient(#1d1b52, #433078 60%, #8e4c78); box-shadow: inset 0 0 35px #08091d80; touch-action: manipulation; }
|
||||
.tower-sky { position: absolute; inset: 0; pointer-events: none; }
|
||||
.tower-sky i { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: #fff; box-shadow: 0 0 7px #c5c2ff; opacity: .65; }
|
||||
.tower-sky i:nth-child(1) { top: 8%; left: 12%; } .tower-sky i:nth-child(2) { top: 17%; left: 72%; } .tower-sky i:nth-child(3) { top: 28%; left: 42%; } .tower-sky i:nth-child(4) { top: 37%; left: 88%; } .tower-sky i:nth-child(5) { top: 46%; left: 18%; } .tower-sky i:nth-child(6) { top: 58%; left: 64%; } .tower-sky i:nth-child(7) { top: 70%; left: 31%; } .tower-sky i:nth-child(8) { top: 11%; left: 91%; } .tower-sky i:nth-child(9) { top: 22%; left: 25%; } .tower-sky i:nth-child(10) { top: 50%; left: 78%; } .tower-sky i:nth-child(11) { top: 64%; left: 8%; } .tower-sky i:nth-child(12) { top: 77%; left: 92%; } .tower-sky i:nth-child(13) { top: 33%; left: 58%; }
|
||||
@@ -343,13 +355,13 @@ onBeforeUnmount(() => {
|
||||
.tower-perfect { position: absolute; z-index: 8; top: 24%; left: 50%; padding: 8px 17px; border-radius: 18px; color: #332149; background: #ffdc65; box-shadow: 0 0 24px #ffcf64aa; font-size: 13px; font-weight: 950; letter-spacing: .8px; transform: translateX(-50%); animation: tower-perfect-pop 650ms ease-out forwards; }
|
||||
.tower-stage--perfect { animation: tower-perfect-glow 500ms ease-out; }
|
||||
.tower-stage--missed { animation: tower-stage-shake 500ms ease-out; }
|
||||
.tower-game__hint { margin-top: 8px; text-align: center; }
|
||||
.tower-overlay { position: absolute; z-index: 15; inset: 47px 0 25px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 28px; border-radius: 22px; color: #f7f2ff; background: #11122bd9; backdrop-filter: blur(7px); text-align: center; }
|
||||
.tower-game__hint { position: absolute; z-index: 6; right: 45px; bottom: 27px; left: 45px; margin: 0; padding: 7px 10px; border-radius: 999px; color: #e0daf0; background: #29245191; backdrop-filter: blur(10px); text-align: center; pointer-events: none; }
|
||||
.tower-overlay { position: absolute; z-index: 15; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 28px; color: #f7f2ff; background: #11122bd9; backdrop-filter: blur(7px); text-align: center; }
|
||||
.tower-overlay > svg { color: #ffbd4d; }
|
||||
.tower-overlay > span { color: #ffad78; font-size: 12px; font-weight: 900; letter-spacing: 1.2px; text-transform: uppercase; }
|
||||
.tower-overlay h2 { margin: 0; font-size: 25px; }
|
||||
.tower-overlay p { margin: -3px 0 5px; color: #c7c1e4; font-size: 14px; }
|
||||
@keyframes tower-preview-slide { from { transform: translateX(0) rotate(-2deg); } to { transform: translateX(66px) rotate(2deg); } }
|
||||
@keyframes tower-preview-slide { from { transform: translateX(0) rotate(-2deg); } to { transform: translateX(48px) rotate(2deg); } }
|
||||
@keyframes tower-land { from { filter: brightness(1.8); transform: translateY(-8px) scaleY(.85); } to { filter: brightness(1); transform: translateY(0) scaleY(1); } }
|
||||
@keyframes tower-active-pulse { from { filter: saturate(1.2) brightness(1.08); } to { filter: saturate(1.45) brightness(1.35); } }
|
||||
@keyframes tower-fall { 0% { opacity: 1; transform: translate(0) rotate(0); } 100% { opacity: 0; transform: translate(45px, 390px) rotate(145deg); } }
|
||||
|
||||
@@ -738,6 +738,8 @@ const deviceData = {
|
||||
revision: 1,
|
||||
},
|
||||
}
|
||||
let mockPasscode = ''
|
||||
let mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
|
||||
let mockContacts = [
|
||||
{
|
||||
created_at: isoTime(-14 * 86_400_000),
|
||||
@@ -1517,6 +1519,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
},
|
||||
},
|
||||
notes: mockNotes,
|
||||
security: mockSecurity,
|
||||
token: 'development',
|
||||
},
|
||||
})
|
||||
@@ -1749,12 +1752,56 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
response.json({ success: true, data: { revision } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'security:unlock') {
|
||||
response.json(
|
||||
!mockSecurity.enabled || request.body.passcode === mockPasscode
|
||||
? { success: true, data: { security: mockSecurity } }
|
||||
: { success: false, error: 'invalid_passcode' },
|
||||
)
|
||||
return
|
||||
}
|
||||
if (endpoint === 'security:set-passcode') {
|
||||
mockPasscode = String(request.body.passcode)
|
||||
mockSecurity = {
|
||||
enabled: true,
|
||||
length: mockPasscode.length,
|
||||
lockedUntil: 0,
|
||||
}
|
||||
response.json({ success: true, data: { security: mockSecurity } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'security:change-passcode') {
|
||||
if (request.body.currentPasscode !== mockPasscode) {
|
||||
response.json({ success: false, error: 'invalid_passcode' })
|
||||
return
|
||||
}
|
||||
mockPasscode = String(request.body.newPasscode)
|
||||
mockSecurity = {
|
||||
enabled: true,
|
||||
length: mockPasscode.length,
|
||||
lockedUntil: 0,
|
||||
}
|
||||
response.json({ success: true, data: { security: mockSecurity } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'security:disable-passcode') {
|
||||
if (request.body.passcode !== mockPasscode) {
|
||||
response.json({ success: false, error: 'invalid_passcode' })
|
||||
return
|
||||
}
|
||||
mockPasscode = ''
|
||||
mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
|
||||
response.json({ success: true, data: { security: mockSecurity } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'device:factory-reset') {
|
||||
authenticated = false
|
||||
linkedAccount = null
|
||||
mockNotes = []
|
||||
mockMedia = []
|
||||
calendarEvents = []
|
||||
mockPasscode = ''
|
||||
mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
|
||||
for (const key of Object.keys(deviceData)) delete deviceData[key]
|
||||
response.json({ success: true })
|
||||
return
|
||||
|
||||
@@ -15,10 +15,17 @@ Config.Command = "phone"
|
||||
|
||||
Config.Phone = {
|
||||
Item = "phone",
|
||||
DevelopmentCommand = true,
|
||||
DevelopmentCommand = false,
|
||||
DeviceName = "iFruit Phone",
|
||||
}
|
||||
|
||||
Config.Security = {
|
||||
PasscodePepperConvar = "sky_phone_passcode_pepper",
|
||||
MaximumAttempts = 5,
|
||||
LockSeconds = 30,
|
||||
AttemptsPerMinute = 12,
|
||||
}
|
||||
|
||||
Config.Sim = {
|
||||
RegisteredItem = "sky_phone_sim_registered",
|
||||
AnonymousItem = "sky_phone_sim_anonymous",
|
||||
@@ -84,6 +91,40 @@ Config.Radio = {
|
||||
},
|
||||
}
|
||||
|
||||
Config.Animations = {
|
||||
Enabled = true,
|
||||
PropModel = "prop_npc_phone_02",
|
||||
PropBone = 28422,
|
||||
LoadTimeoutMs = 5000,
|
||||
ContextPollMs = 250,
|
||||
Dictionaries = {
|
||||
OnFoot = "cellphone@",
|
||||
Driver = "anim@cellphone@in_car@ds",
|
||||
Passenger = "anim@cellphone@in_car@ps",
|
||||
Selfie = "anim@mp_player_intuppertake_selfie",
|
||||
},
|
||||
Clips = {
|
||||
TextIn = "cellphone_text_in",
|
||||
TextRead = "cellphone_text_read_base",
|
||||
TextOut = "cellphone_text_out",
|
||||
TextToCall = "cellphone_text_to_call",
|
||||
CallListen = "cellphone_call_listen_base",
|
||||
CallToText = "cellphone_call_to_text",
|
||||
CallOut = "cellphone_call_out",
|
||||
Selfie = "idle_a",
|
||||
},
|
||||
Transforms = {
|
||||
Portrait = {
|
||||
position = { x = 0.0, y = 0.0, z = 0.0 },
|
||||
rotation = { x = 0.0, y = 0.0, z = 0.0 },
|
||||
},
|
||||
Landscape = {
|
||||
position = { x = 0.0, y = 0.0, z = 0.0 },
|
||||
rotation = { x = 0.0, y = 0.0, z = 90.0 },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Config.Messages = {
|
||||
BodyMaxLength = 2000,
|
||||
ConversationScanLimit = 1000,
|
||||
|
||||
@@ -29,10 +29,15 @@ Locales["en"] = {
|
||||
Notifications = { now = "now" },
|
||||
LockScreen = {
|
||||
label = "Lock Screen", flashlight = "Flashlight", camera = "Camera", swipeUp = "Swipe up to open",
|
||||
passcode = {
|
||||
enter = "Enter Passcode", unlockSubtitle = "Enter the passcode for this phone.", cancel = "Cancel", delete = "Delete digit",
|
||||
incorrect = "Incorrect passcode", locked = "Too many attempts. Try again in {seconds} seconds.",
|
||||
tryAgain = "Try again in {seconds} seconds", rateLimited = "Too many attempts. Please wait.",
|
||||
},
|
||||
},
|
||||
Home = {
|
||||
appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps",
|
||||
dock = "Dock", noApps = "No apps found", removeApp = "Remove {app} from Home Screen", page = "Page", pages = "Home screen pages",
|
||||
dock = "Dock", noApps = "No apps found", removeApp = "Remove {app} from Home Screen", addToHome = "Add {app} to Home Screen", addPage = "Add Home Screen page", deletePage = "Delete current Home Screen page", removedFromHome = "Removed from Home Screen", page = "Page", pages = "Home screen pages",
|
||||
groups = {
|
||||
suggestions = "Suggestions", recentlyAdded = "Recently Added", games = "Games",
|
||||
productivity = "Productivity", shopping = "Shopping", social = "Social Networks", utilities = "Utilities",
|
||||
@@ -44,6 +49,19 @@ Locales["en"] = {
|
||||
battery = { label = "Phone" },
|
||||
media = { title = "Night Drive", artist = "Sky Radio", play = "Play", pause = "Pause" },
|
||||
},
|
||||
widgetSystem = {
|
||||
galleryTitle = "Widgets", search = "Search Widgets", size = "Size", add = "Add Widget", addWidget = "Add Widget",
|
||||
editWidget = "Edit Widget", removeWidget = "Remove Widget", remove = "Remove widget", configure = "Configure Widget", noResults = "No widgets found",
|
||||
sizes = { small = "Small", medium = "Medium", large = "Large" },
|
||||
categories = { essentials = "Essentials", information = "Information", media = "Media", finance = "Finance", people = "People" },
|
||||
clock = { name = "Clock", description = "The current time, with an optional date.", showDate = "Show Date" },
|
||||
date = { name = "Date", description = "Weekday, month, and date at a glance." },
|
||||
weather = { name = "Weather", description = "Current conditions, location, and high and low." },
|
||||
music = { name = "Now Playing", description = "Music controls and the current track." },
|
||||
wallet = { name = "Wallet", description = "Your current bank or cash balance.", balance = "Displayed Balance", bank = "Bank", cash = "Cash" },
|
||||
transactions = { name = "Transactions", description = "Your latest incoming and outgoing payments." },
|
||||
contacts = { name = "Favorites", description = "Call or message your favorite contacts.", choose = "Favorite Contacts" },
|
||||
},
|
||||
},
|
||||
Apps = {
|
||||
darkchat = {
|
||||
@@ -240,7 +258,7 @@ Locales["en"] = {
|
||||
readyBody = "Collect fruit, grow longer, and stay clear of every wall.", readyTitle = "Ready to play?",
|
||||
restart = "Play Again", resume = "Resume game", score = "Score", speed = "Speed",
|
||||
speeds = { fast = "Fast", normal = "Normal", relaxed = "Relaxed" }, start = "Start Game",
|
||||
swipeHint = "Swipe, tap the controls, or use arrow keys / WASD",
|
||||
swipeHint = "Use the arrow keys to steer",
|
||||
},
|
||||
memory = {
|
||||
name = "Memory", backToMenu = "Back to board selection", board = "Memory card board",
|
||||
@@ -266,7 +284,7 @@ Locales["en"] = {
|
||||
keepPlaying = "Keep Playing", mainMenu = "Main Menu",
|
||||
menuBody = "Slide matching numbers together and work your way up to 2048.", menuTitle = "Build the 2048 tile",
|
||||
mute = "Mute game sounds", newGame = "New Game", score = "Score",
|
||||
swipeHint = "Swipe the board, use the buttons, or press arrow keys / WASD",
|
||||
swipeHint = "Swipe the board or use arrow keys / WASD",
|
||||
unmute = "Turn on game sounds",
|
||||
wonBody = "You reached the legendary tile. Continue climbing or begin again.", wonTitle = "You made 2048!",
|
||||
},
|
||||
@@ -435,7 +453,7 @@ Locales["en"] = {
|
||||
reportReasons = { prohibited = "Prohibited item", fraud = "Suspected fraud", spam = "Spam", offensive = "Offensive content", other = "Other" },
|
||||
searchPlaceholder = "What are you looking for?", allCategories = "All categories", allDistricts = "All districts",
|
||||
sortNewest = "Newest first", sortPriceAsc = "Lowest price", sortPriceDesc = "Highest price",
|
||||
freshOffers = "Fresh offers", offers = "offers", noListings = "No offers found",
|
||||
freshOffers = "Fresh offers", offers = "offers", compactView = "Compact view", largeView = "Large view", noListings = "No offers found",
|
||||
noListingsBody = "Try another search or category.", noDistrict = "No district",
|
||||
free = "Free", negotiablePrice = "${price} negotiable", money = "${price}",
|
||||
hoursAgo = "{count}h ago", daysAgo = "{count}d ago", photos = "photos", activeListings = "active listings",
|
||||
@@ -559,7 +577,7 @@ Locales["en"] = {
|
||||
accountInformation = "Account Information", accountStatus = "Account Status", accountStatusValue = "Active",
|
||||
accountStorage = "Cloud Storage", accountStorageValue = "On Device", accountPurchases = "Media & Purchases",
|
||||
accountPurchasesValue = "Available", notifications = "Notifications", sounds = "Sounds & Haptics",
|
||||
general = "General Settings", appearance = "Appearance", allowNotifications = "Allow Notifications",
|
||||
general = "General Settings", security = "Passcode & Security", appearance = "Appearance", allowNotifications = "Allow Notifications",
|
||||
notificationSounds = "Sounds", notificationDuration = "Notification Duration", seconds = "{seconds} seconds",
|
||||
ringtoneVolume = "Ringtone Volume", notificationVolume = "Notification Volume",
|
||||
ringtone = "Ringtone", notificationSound = "Notification Sound", appearanceMode = "Appearance Mode",
|
||||
@@ -573,6 +591,15 @@ Locales["en"] = {
|
||||
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.",
|
||||
factoryResetProgress = "Erasing iFruit Phone", factoryResetWarning = "Do not turn off this phone. This takes 60 seconds.",
|
||||
passcode = {
|
||||
description = "A passcode protects the contents of this phone. It stays with the device when the SIM or iFruit account changes.",
|
||||
status = "Passcode", codeLength = "Code Length", sixDigit = "6-Digit Code", fourDigit = "4-Digit Code",
|
||||
turnOn = "Turn Passcode On", turnOff = "Turn Passcode Off", change = "Change Passcode",
|
||||
enterNew = "Enter New Passcode", confirmNew = "Verify New Passcode", enterCurrent = "Enter Current Passcode",
|
||||
screenSubtitle = "Use 4 or 6 numbers.", incorrect = "Incorrect passcode.", mismatch = "The passcodes did not match.",
|
||||
locked = "Too many incorrect attempts. Try again later.", rateLimited = "Too many attempts. Please wait.",
|
||||
failed = "The passcode could not be updated.", saved = "Passcode saved.", disabled = "Passcode turned off.",
|
||||
},
|
||||
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.",
|
||||
|
||||
@@ -24,6 +24,7 @@ client_scripts {
|
||||
'config/locales/*.lua',
|
||||
'source/bridge/client/framework.lua',
|
||||
'source/bridge/client/callbacks.lua',
|
||||
'source/client/animations.lua',
|
||||
'source/client/camera.lua',
|
||||
'source/client/garage.lua',
|
||||
'source/bridge/client/radio.lua',
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
local MODE_HIDDEN = "hidden"
|
||||
local MODE_PHONE_READ = "phone_read"
|
||||
local MODE_CALL = "call"
|
||||
local MODE_CAMERA_REAR = "camera_rear"
|
||||
local MODE_CAMERA_SELFIE = "camera_selfie"
|
||||
local LOOPED_UPPER_BODY_FLAGS = 49
|
||||
local TRANSITION_UPPER_BODY_FLAGS = 48
|
||||
|
||||
local animation_state = {
|
||||
call_direction = nil,
|
||||
call_state = nil,
|
||||
camera_active = false,
|
||||
camera_front = false,
|
||||
camera_landscape = false,
|
||||
current_animation = nil,
|
||||
current_mode = MODE_HIDDEN,
|
||||
ped = nil,
|
||||
phone_open = false,
|
||||
prop = nil,
|
||||
revision = 0,
|
||||
watcher_active = false,
|
||||
}
|
||||
|
||||
local reevaluate
|
||||
|
||||
local function load_model(model_hash)
|
||||
RequestModel(model_hash)
|
||||
local deadline = GetGameTimer() + Config.Animations.LoadTimeoutMs
|
||||
while not HasModelLoaded(model_hash) do
|
||||
if GetGameTimer() >= deadline then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Timed out loading phone prop model '%s'.",
|
||||
Config.Animations.PropModel
|
||||
)
|
||||
return false
|
||||
end
|
||||
Wait(0)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function load_animation_dictionary(dictionary)
|
||||
RequestAnimDict(dictionary)
|
||||
local deadline = GetGameTimer() + Config.Animations.LoadTimeoutMs
|
||||
while not HasAnimDictLoaded(dictionary) do
|
||||
if GetGameTimer() >= deadline then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Timed out loading animation dictionary '%s'.",
|
||||
dictionary
|
||||
)
|
||||
return false
|
||||
end
|
||||
Wait(0)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function can_animate(ped)
|
||||
if not DoesEntityExist(ped) or IsEntityDead(ped) then
|
||||
return false
|
||||
end
|
||||
if IsPedRagdoll(ped) or IsPedFalling(ped) or IsPedClimbing(ped) then
|
||||
return false
|
||||
end
|
||||
if IsPedSwimming(ped) or IsPedSwimmingUnderWater(ped) or IsPedInParachuteFreeFall(ped) then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function get_phone_dictionary(ped)
|
||||
if not IsPedInAnyVehicle(ped, false) then
|
||||
return Config.Animations.Dictionaries.OnFoot, "on_foot"
|
||||
end
|
||||
|
||||
local vehicle = GetVehiclePedIsIn(ped, false)
|
||||
if GetPedInVehicleSeat(vehicle, -1) == ped then
|
||||
return Config.Animations.Dictionaries.Driver, "driver:" .. tostring(vehicle)
|
||||
end
|
||||
return Config.Animations.Dictionaries.Passenger, "passenger:" .. tostring(vehicle)
|
||||
end
|
||||
|
||||
local function derive_mode()
|
||||
if not Config.Animations.Enabled then
|
||||
return MODE_HIDDEN
|
||||
end
|
||||
if animation_state.call_state == "connected" then
|
||||
return MODE_CALL
|
||||
end
|
||||
if animation_state.call_state == "ringing" and animation_state.call_direction == "outgoing" then
|
||||
return MODE_CALL
|
||||
end
|
||||
if animation_state.camera_active and animation_state.camera_front then
|
||||
return MODE_CAMERA_SELFIE
|
||||
end
|
||||
if animation_state.camera_active then
|
||||
return MODE_CAMERA_REAR
|
||||
end
|
||||
if animation_state.call_state == "ringing" or animation_state.phone_open then
|
||||
return MODE_PHONE_READ
|
||||
end
|
||||
return MODE_HIDDEN
|
||||
end
|
||||
|
||||
local function stop_current_animation()
|
||||
local current = animation_state.current_animation
|
||||
if not current then
|
||||
return
|
||||
end
|
||||
|
||||
local ped = animation_state.ped
|
||||
if ped and DoesEntityExist(ped) then
|
||||
StopAnimTask(ped, current.dictionary, current.clip, 3.0)
|
||||
end
|
||||
animation_state.current_animation = nil
|
||||
end
|
||||
|
||||
local function delete_phone_prop()
|
||||
local prop = animation_state.prop
|
||||
if not prop then
|
||||
animation_state.ped = nil
|
||||
return true
|
||||
end
|
||||
if not DoesEntityExist(prop) then
|
||||
animation_state.prop = nil
|
||||
animation_state.ped = nil
|
||||
return true
|
||||
end
|
||||
|
||||
DetachEntity(prop, true, true)
|
||||
SetEntityAsMissionEntity(prop, true, true)
|
||||
DeleteEntity(prop)
|
||||
if DoesEntityExist(prop) then
|
||||
Bridge.Debug("error", "[sky_phone] Failed to delete the attached phone prop.")
|
||||
return false
|
||||
end
|
||||
|
||||
animation_state.prop = nil
|
||||
animation_state.ped = nil
|
||||
return true
|
||||
end
|
||||
|
||||
local function cleanup_phone()
|
||||
stop_current_animation()
|
||||
delete_phone_prop()
|
||||
end
|
||||
|
||||
local function ensure_phone_prop(ped, revision)
|
||||
if animation_state.prop and DoesEntityExist(animation_state.prop) and animation_state.ped == ped then
|
||||
return true
|
||||
end
|
||||
|
||||
if animation_state.prop then
|
||||
delete_phone_prop()
|
||||
end
|
||||
|
||||
local model_hash = joaat(Config.Animations.PropModel)
|
||||
if not load_model(model_hash) then
|
||||
return false
|
||||
end
|
||||
if animation_state.revision ~= revision then
|
||||
SetModelAsNoLongerNeeded(model_hash)
|
||||
return false
|
||||
end
|
||||
|
||||
local coords = GetEntityCoords(ped)
|
||||
local prop = CreateObject(model_hash, coords.x, coords.y, coords.z, true, true, false)
|
||||
SetModelAsNoLongerNeeded(model_hash)
|
||||
if not prop or prop == 0 or not DoesEntityExist(prop) then
|
||||
Bridge.Debug("error", "[sky_phone] Failed to create the phone prop.")
|
||||
return false
|
||||
end
|
||||
|
||||
SetEntityCollision(prop, false, false)
|
||||
animation_state.prop = prop
|
||||
animation_state.ped = ped
|
||||
return true
|
||||
end
|
||||
|
||||
local function get_transform(mode)
|
||||
if (mode == MODE_CAMERA_REAR or mode == MODE_CAMERA_SELFIE) and animation_state.camera_landscape then
|
||||
return Config.Animations.Transforms.Landscape
|
||||
end
|
||||
return Config.Animations.Transforms.Portrait
|
||||
end
|
||||
|
||||
local function attach_phone_prop(ped, mode)
|
||||
local transform = get_transform(mode)
|
||||
local position = transform.position
|
||||
local rotation = transform.rotation
|
||||
AttachEntityToEntity(
|
||||
animation_state.prop,
|
||||
ped,
|
||||
GetPedBoneIndex(ped, Config.Animations.PropBone),
|
||||
position.x,
|
||||
position.y,
|
||||
position.z,
|
||||
rotation.x,
|
||||
rotation.y,
|
||||
rotation.z,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
2,
|
||||
true
|
||||
)
|
||||
end
|
||||
|
||||
local function play_animation(ped, dictionary, clip, looped, revision)
|
||||
if not load_animation_dictionary(dictionary) or animation_state.revision ~= revision then
|
||||
return nil
|
||||
end
|
||||
|
||||
local duration = -1
|
||||
local flags = LOOPED_UPPER_BODY_FLAGS
|
||||
if not looped then
|
||||
local duration_seconds = GetAnimDuration(dictionary, clip)
|
||||
if duration_seconds <= 0.0 then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Animation '%s' was not found in dictionary '%s'.",
|
||||
clip,
|
||||
dictionary
|
||||
)
|
||||
return nil
|
||||
end
|
||||
duration = math.max(1, math.floor(duration_seconds * 1000.0))
|
||||
flags = TRANSITION_UPPER_BODY_FLAGS
|
||||
end
|
||||
|
||||
stop_current_animation()
|
||||
TaskPlayAnim(ped, dictionary, clip, 4.0, -4.0, duration, flags, 1.0, false, false, false)
|
||||
animation_state.current_animation = {
|
||||
clip = clip,
|
||||
dictionary = dictionary,
|
||||
}
|
||||
return duration
|
||||
end
|
||||
|
||||
local function get_base_animation(ped, mode)
|
||||
local dictionary = get_phone_dictionary(ped)
|
||||
if mode == MODE_CALL then
|
||||
return dictionary, Config.Animations.Clips.CallListen
|
||||
end
|
||||
if mode == MODE_CAMERA_SELFIE and not IsPedInAnyVehicle(ped, false) then
|
||||
return Config.Animations.Dictionaries.Selfie, Config.Animations.Clips.Selfie
|
||||
end
|
||||
return dictionary, Config.Animations.Clips.TextRead
|
||||
end
|
||||
|
||||
local function get_transition_clip(previous_mode, mode)
|
||||
if previous_mode == MODE_HIDDEN then
|
||||
return Config.Animations.Clips.TextIn
|
||||
end
|
||||
if previous_mode == MODE_CALL and mode ~= MODE_CALL then
|
||||
return Config.Animations.Clips.CallToText
|
||||
end
|
||||
if previous_mode ~= MODE_CALL and mode == MODE_CALL then
|
||||
return Config.Animations.Clips.TextToCall
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function apply_visible_mode(previous_mode, mode, revision)
|
||||
local ped = PlayerPedId()
|
||||
if not can_animate(ped) or not ensure_phone_prop(ped, revision) then
|
||||
return
|
||||
end
|
||||
|
||||
attach_phone_prop(ped, mode)
|
||||
local transition_clip = get_transition_clip(previous_mode, mode)
|
||||
if transition_clip then
|
||||
local dictionary = get_phone_dictionary(ped)
|
||||
local duration = play_animation(ped, dictionary, transition_clip, false, revision)
|
||||
if not duration then
|
||||
cleanup_phone()
|
||||
return
|
||||
end
|
||||
Wait(duration)
|
||||
if animation_state.revision ~= revision or not can_animate(ped) then
|
||||
return
|
||||
end
|
||||
attach_phone_prop(ped, mode)
|
||||
end
|
||||
|
||||
local dictionary, clip = get_base_animation(ped, mode)
|
||||
if not play_animation(ped, dictionary, clip, true, revision) then
|
||||
cleanup_phone()
|
||||
end
|
||||
end
|
||||
|
||||
local function apply_hidden_mode(previous_mode, revision)
|
||||
local ped = animation_state.ped
|
||||
if not ped or not DoesEntityExist(ped) or not animation_state.prop or not DoesEntityExist(animation_state.prop) then
|
||||
cleanup_phone()
|
||||
return
|
||||
end
|
||||
|
||||
local clip = previous_mode == MODE_CALL and Config.Animations.Clips.CallOut or Config.Animations.Clips.TextOut
|
||||
local dictionary = get_phone_dictionary(ped)
|
||||
local duration = play_animation(ped, dictionary, clip, false, revision)
|
||||
if not duration then
|
||||
cleanup_phone()
|
||||
return
|
||||
end
|
||||
Wait(duration)
|
||||
if animation_state.revision == revision then
|
||||
cleanup_phone()
|
||||
end
|
||||
end
|
||||
|
||||
local function apply_mode(previous_mode, mode, revision)
|
||||
if mode == MODE_HIDDEN then
|
||||
apply_hidden_mode(previous_mode, revision)
|
||||
return
|
||||
end
|
||||
apply_visible_mode(previous_mode, mode, revision)
|
||||
end
|
||||
|
||||
local function ensure_context_watcher()
|
||||
if animation_state.watcher_active or derive_mode() == MODE_HIDDEN then
|
||||
return
|
||||
end
|
||||
|
||||
animation_state.watcher_active = true
|
||||
CreateThread(function()
|
||||
local observed_ped = nil
|
||||
local observed_context = nil
|
||||
local was_available = false
|
||||
while derive_mode() ~= MODE_HIDDEN do
|
||||
local ped = PlayerPedId()
|
||||
local available = can_animate(ped)
|
||||
local context = "unavailable"
|
||||
if available then
|
||||
local _, current_context = get_phone_dictionary(ped)
|
||||
context = current_context
|
||||
end
|
||||
|
||||
if not available and (was_available or animation_state.prop) then
|
||||
animation_state.revision = animation_state.revision + 1
|
||||
animation_state.current_mode = MODE_HIDDEN
|
||||
cleanup_phone()
|
||||
elseif available and (not was_available or observed_ped ~= ped or observed_context ~= context) then
|
||||
reevaluate(true)
|
||||
end
|
||||
|
||||
observed_ped = ped
|
||||
observed_context = context
|
||||
was_available = available
|
||||
Wait(Config.Animations.ContextPollMs)
|
||||
end
|
||||
animation_state.watcher_active = false
|
||||
end)
|
||||
end
|
||||
|
||||
reevaluate = function(force)
|
||||
local mode = derive_mode()
|
||||
local ped = PlayerPedId()
|
||||
if mode ~= MODE_HIDDEN and not can_animate(ped) then
|
||||
animation_state.revision = animation_state.revision + 1
|
||||
animation_state.current_mode = MODE_HIDDEN
|
||||
cleanup_phone()
|
||||
ensure_context_watcher()
|
||||
return
|
||||
end
|
||||
if mode == animation_state.current_mode and not force then
|
||||
ensure_context_watcher()
|
||||
return
|
||||
end
|
||||
|
||||
local previous_mode = animation_state.current_mode
|
||||
animation_state.current_mode = mode
|
||||
animation_state.revision = animation_state.revision + 1
|
||||
local revision = animation_state.revision
|
||||
ensure_context_watcher()
|
||||
CreateThread(function()
|
||||
apply_mode(previous_mode, mode, revision)
|
||||
end)
|
||||
end
|
||||
|
||||
local function reset_animation_state()
|
||||
animation_state.phone_open = false
|
||||
animation_state.call_state = nil
|
||||
animation_state.call_direction = nil
|
||||
animation_state.camera_active = false
|
||||
animation_state.camera_front = false
|
||||
animation_state.camera_landscape = false
|
||||
animation_state.current_mode = MODE_HIDDEN
|
||||
animation_state.revision = animation_state.revision + 1
|
||||
cleanup_phone()
|
||||
end
|
||||
|
||||
AddEventHandler("sky_phone:animation:phone", function(open)
|
||||
if type(open) ~= "boolean" then
|
||||
Bridge.Debug("warn", "[sky_phone] Ignored invalid phone animation state.")
|
||||
return
|
||||
end
|
||||
animation_state.phone_open = open
|
||||
|
||||
if not open then
|
||||
animation_state.camera_active = false
|
||||
animation_state.camera_front = false
|
||||
animation_state.camera_landscape = false
|
||||
|
||||
if derive_mode() == MODE_HIDDEN then
|
||||
animation_state.current_mode = MODE_HIDDEN
|
||||
animation_state.revision = animation_state.revision + 1
|
||||
cleanup_phone()
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
reevaluate(false)
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:animation:call", function(data)
|
||||
if type(data) ~= "table" or type(data.state) ~= "string" or type(data.direction) ~= "string" then
|
||||
Bridge.Debug("warn", "[sky_phone] Ignored invalid call animation state.")
|
||||
return
|
||||
end
|
||||
animation_state.call_state = data.state
|
||||
animation_state.call_direction = data.direction
|
||||
reevaluate(false)
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:animation:camera", function(data)
|
||||
if type(data) ~= "table" or type(data.active) ~= "boolean" then
|
||||
Bridge.Debug("warn", "[sky_phone] Ignored invalid camera animation state.")
|
||||
return
|
||||
end
|
||||
animation_state.camera_active = data.active and animation_state.phone_open
|
||||
animation_state.camera_front = data.front == true
|
||||
animation_state.camera_landscape = data.landscape == true
|
||||
reevaluate(true)
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:animation:reset", reset_animation_state)
|
||||
|
||||
AddEventHandler("onResourceStop", function(resource_name)
|
||||
if resource_name == GetCurrentResourceName() then
|
||||
reset_animation_state()
|
||||
end
|
||||
end)
|
||||
@@ -14,6 +14,7 @@ local camera_state = {
|
||||
focus_watcher = false,
|
||||
front_camera = false,
|
||||
front_camera_handle = nil,
|
||||
landscape = false,
|
||||
ultrawide_camera_handle = nil,
|
||||
nui_focused = true,
|
||||
previous_ped_view = nil,
|
||||
@@ -181,6 +182,7 @@ local function set_camera_active(active)
|
||||
camera_state.active = active
|
||||
if active then
|
||||
camera_state.front_camera = false
|
||||
camera_state.landscape = false
|
||||
camera_state.zoom = 1.0
|
||||
clear_front_camera()
|
||||
clear_ultrawide_camera()
|
||||
@@ -190,6 +192,11 @@ local function set_camera_active(active)
|
||||
DisplayRadar(false)
|
||||
set_camera_focus(true)
|
||||
apply_camera_view()
|
||||
TriggerEvent("sky_phone:animation:camera", {
|
||||
active = true,
|
||||
front = camera_state.front_camera,
|
||||
landscape = camera_state.landscape,
|
||||
})
|
||||
if camera_state.enforcing then
|
||||
return
|
||||
end
|
||||
@@ -240,6 +247,7 @@ local function set_camera_active(active)
|
||||
end
|
||||
set_flash_enabled(false)
|
||||
camera_state.front_camera = false
|
||||
camera_state.landscape = false
|
||||
clear_front_camera()
|
||||
clear_ultrawide_camera()
|
||||
restore_camera_view()
|
||||
@@ -248,6 +256,11 @@ local function set_camera_active(active)
|
||||
SetNuiFocusKeepInput(false)
|
||||
SetNuiFocus(true, true)
|
||||
end
|
||||
TriggerEvent("sky_phone:animation:camera", {
|
||||
active = false,
|
||||
front = false,
|
||||
landscape = false,
|
||||
})
|
||||
end
|
||||
|
||||
local function set_front_camera(active)
|
||||
@@ -268,6 +281,25 @@ local function set_front_camera(active)
|
||||
end
|
||||
end
|
||||
apply_camera_view()
|
||||
TriggerEvent("sky_phone:animation:camera", {
|
||||
active = true,
|
||||
front = camera_state.front_camera,
|
||||
landscape = camera_state.landscape,
|
||||
})
|
||||
end
|
||||
|
||||
local function set_camera_landscape(active)
|
||||
if camera_state.landscape == active then
|
||||
return
|
||||
end
|
||||
camera_state.landscape = active
|
||||
if camera_state.active then
|
||||
TriggerEvent("sky_phone:animation:camera", {
|
||||
active = true,
|
||||
front = camera_state.front_camera,
|
||||
landscape = camera_state.landscape,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local function set_camera_zoom(zoom)
|
||||
@@ -314,6 +346,11 @@ RegisterNUICallback("camera:setFacing", function(data, cb)
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback("camera:setOrientation", function(data, cb)
|
||||
set_camera_landscape(data and data.landscape == true)
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
RegisterNUICallback("camera:setZoom", function(data, cb)
|
||||
cb({ success = set_camera_zoom(tonumber(data and data.zoom)) })
|
||||
end)
|
||||
|
||||
@@ -8,6 +8,10 @@ local call_channel = 0
|
||||
Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true })
|
||||
|
||||
local server_callbacks = {
|
||||
"security:unlock",
|
||||
"security:set-passcode",
|
||||
"security:change-passcode",
|
||||
"security:disable-passcode",
|
||||
"device:save",
|
||||
"device:factory-reset",
|
||||
"account:login",
|
||||
@@ -124,6 +128,7 @@ end
|
||||
|
||||
local function close_phone()
|
||||
open_requested = false
|
||||
TriggerEvent("sky_phone:animation:phone", false)
|
||||
if not is_open then
|
||||
return
|
||||
end
|
||||
@@ -193,6 +198,7 @@ RegisterNUICallback("ui:opened", function(_, cb)
|
||||
is_open = true
|
||||
notification_focus = false
|
||||
SetNuiFocus(true, true)
|
||||
TriggerEvent("sky_phone:animation:phone", true)
|
||||
cb({ success = true })
|
||||
end)
|
||||
|
||||
@@ -352,6 +358,7 @@ end)
|
||||
RegisterNetEvent("sky_phone:device:invalidated", function()
|
||||
open_requested = false
|
||||
device_payload = nil
|
||||
TriggerEvent("sky_phone:animation:reset")
|
||||
close_phone()
|
||||
end)
|
||||
|
||||
@@ -466,6 +473,7 @@ end)
|
||||
RegisterNetEvent("sky_phone:call:incoming", function(data)
|
||||
notification_focus = true
|
||||
SetNuiFocus(true, true)
|
||||
TriggerEvent("sky_phone:animation:call", data)
|
||||
SendNUIMessage({ type = "call:incoming", data = data })
|
||||
end)
|
||||
|
||||
@@ -477,6 +485,7 @@ RegisterNetEvent("sky_phone:call:state", function(data)
|
||||
elseif data.state ~= "ringing" then
|
||||
leave_call_voice()
|
||||
end
|
||||
TriggerEvent("sky_phone:animation:call", data)
|
||||
SendNUIMessage({ type = "call:state", data = data })
|
||||
end)
|
||||
|
||||
@@ -495,6 +504,7 @@ AddEventHandler("onResourceStop", function(resource_name)
|
||||
SetNuiFocus(false, false)
|
||||
end
|
||||
|
||||
TriggerEvent("sky_phone:animation:reset")
|
||||
leave_call_voice()
|
||||
|
||||
if Config.Phone.DevelopmentCommand then
|
||||
|
||||
@@ -208,6 +208,39 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_device_security",
|
||||
columns = {
|
||||
{
|
||||
name = "device_imei",
|
||||
type = "CHAR(15) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "passcode_hash", type = "BINARY(32) NOT NULL" },
|
||||
{
|
||||
name = "passcode_salt",
|
||||
type = "CHAR(32) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "passcode_length", type = "TINYINT UNSIGNED NOT NULL" },
|
||||
{ name = "failed_attempts", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "locked_until", type = "BIGINT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{
|
||||
name = "updated_at",
|
||||
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
|
||||
},
|
||||
},
|
||||
primaryKey = "device_imei",
|
||||
foreignKeys = {
|
||||
{
|
||||
column = "device_imei",
|
||||
references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE",
|
||||
},
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_notes",
|
||||
columns = {
|
||||
@@ -436,7 +469,7 @@ local schema = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "listing_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "media_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "gradient", type = "VARCHAR(160) NOT NULL" },
|
||||
{ name = "gradient", type = "VARCHAR(2200) NOT NULL" },
|
||||
{ name = "sort_order", type = "TINYINT UNSIGNED NOT NULL" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
@@ -619,7 +652,7 @@ local schema = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "media_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "gradient", type = "VARCHAR(160) NOT NULL" },
|
||||
{ name = "gradient", type = "VARCHAR(2200) NOT NULL" },
|
||||
{ name = "sort_order", type = "TINYINT UNSIGNED NOT NULL" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
@@ -859,6 +892,14 @@ Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_darkchat_messages`
|
||||
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'system') NOT NULL DEFAULT 'text'
|
||||
]], {})
|
||||
Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_marketplace_images`
|
||||
MODIFY COLUMN `gradient` VARCHAR(2200) NOT NULL
|
||||
]], {})
|
||||
Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_pages_images`
|
||||
MODIFY COLUMN `gradient` VARCHAR(2200) NOT NULL
|
||||
]], {})
|
||||
Bridge.Database.EnsureIndex("sky_phone_devices", "uniq_sky_phone_devices_sim", "(`sim_id`)", { unique = true })
|
||||
Bridge.Database.Query("UPDATE `sky_phone_contacts` SET `contact_id` = `id` WHERE `contact_id` IS NULL", {})
|
||||
Bridge.Database.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_account_contact", "(`account_id`, `contact_id`)", { unique = true })
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local categories = {}
|
||||
local districts = {}
|
||||
local photo_gradients = {}
|
||||
local item_conditions = { new = true, very_good = true, used = true, defective = true }
|
||||
local price_types = { fixed = true, negotiable = true, free = true }
|
||||
local report_reasons = { prohibited = true, fraud = true, spam = true, offensive = true, other = true }
|
||||
@@ -15,9 +14,6 @@ end
|
||||
for _, district in ipairs(Config.Marketplace.Districts) do
|
||||
districts[district] = true
|
||||
end
|
||||
for _, gradient in ipairs(Config.Marketplace.PhotoGradients) do
|
||||
photo_gradients[gradient] = true
|
||||
end
|
||||
|
||||
local function trim(value)
|
||||
if type(value) ~= "string" then
|
||||
@@ -96,7 +92,7 @@ local function load_images(listing_id)
|
||||
]], { listing_id })
|
||||
end
|
||||
|
||||
local function validate_images(source, imei, images)
|
||||
local function validate_images(source, images)
|
||||
if type(images) ~= "table" or #images > Config.Marketplace.MaxImages then
|
||||
return nil
|
||||
end
|
||||
@@ -104,30 +100,15 @@ local function validate_images(source, imei, images)
|
||||
return {}
|
||||
end
|
||||
|
||||
local owned_media = {
|
||||
["sunset-drive"] = Config.Marketplace.PhotoGradients[1],
|
||||
["ocean-air"] = Config.Marketplace.PhotoGradients[2],
|
||||
["city-lights"] = Config.Marketplace.PhotoGradients[3],
|
||||
["desert-road"] = Config.Marketplace.PhotoGradients[4],
|
||||
}
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `payload` FROM `sky_phone_device_data`
|
||||
WHERE `device_imei` = ? AND `namespace` = 'media'
|
||||
LIMIT 1
|
||||
]], { imei })
|
||||
local media = rows[1] and json.decode(rows[1].payload) or nil
|
||||
for _, capture in ipairs(type(media) == "table" and media.captures or {}) do
|
||||
if type(capture) == "table" and type(capture.id) == "string" and photo_gradients[capture.gradient] then
|
||||
owned_media[capture.id] = capture.gradient
|
||||
end
|
||||
end
|
||||
|
||||
local normalized = {}
|
||||
local seen = {}
|
||||
for index, image in ipairs(images) do
|
||||
local media_id = type(image) == "table" and image.id or nil
|
||||
local gradient = media_id and owned_media[media_id] or nil
|
||||
if type(media_id) ~= "string" or #media_id > 64 or not gradient or seen[media_id] then
|
||||
local numeric_id = tonumber(media_id)
|
||||
local normalized_id = numeric_id and tostring(math.floor(numeric_id)) or nil
|
||||
if not numeric_id or numeric_id < 1 or numeric_id ~= math.floor(numeric_id)
|
||||
or seen[normalized_id]
|
||||
then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected unowned marketplace image from source %s.",
|
||||
@@ -135,8 +116,17 @@ local function validate_images(source, imei, images)
|
||||
)
|
||||
return nil
|
||||
end
|
||||
seen[media_id] = true
|
||||
normalized[index] = { id = media_id, gradient = gradient }
|
||||
local url = SkyPhoneMedia.ResolveOwnedMedia(source, normalized_id, "photo")
|
||||
if not url then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected unowned marketplace image from source %s.",
|
||||
tostring(source)
|
||||
)
|
||||
return nil
|
||||
end
|
||||
seen[normalized_id] = true
|
||||
normalized[index] = { id = normalized_id, gradient = ("url(%s)"):format(json.encode(url)) }
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
@@ -168,7 +158,7 @@ local function validate_listing(source, account, data)
|
||||
return nil, "phone_unavailable"
|
||||
end
|
||||
|
||||
local images = validate_images(source, account.imei, data.images)
|
||||
local images = validate_images(source, data.images)
|
||||
if not images then
|
||||
return nil, "invalid_images"
|
||||
end
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local categories = {}
|
||||
local districts = {}
|
||||
local photo_gradients = {}
|
||||
for _, value in ipairs(Config.LocalPages.Categories) do categories[value] = true end
|
||||
for _, value in ipairs(Config.Marketplace.Districts) do districts[value] = true end
|
||||
for _, value in ipairs(Config.Marketplace.PhotoGradients) do photo_gradients[value] = true end
|
||||
|
||||
local function trim(value)
|
||||
if type(value) ~= "string" then return nil end
|
||||
@@ -33,39 +31,29 @@ local function load_images(post_id)
|
||||
]], { post_id })
|
||||
end
|
||||
|
||||
local function validate_images(source, imei, images)
|
||||
local function validate_images(source, images)
|
||||
if type(images) ~= "table" or #images > Config.LocalPages.MaxImages then return nil end
|
||||
if #images == 0 then return {} end
|
||||
|
||||
local owned_media = {
|
||||
["sunset-drive"] = Config.Marketplace.PhotoGradients[1],
|
||||
["ocean-air"] = Config.Marketplace.PhotoGradients[2],
|
||||
["city-lights"] = Config.Marketplace.PhotoGradients[3],
|
||||
["desert-road"] = Config.Marketplace.PhotoGradients[4],
|
||||
}
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `payload` FROM `sky_phone_device_data`
|
||||
WHERE `device_imei` = ? AND `namespace` = 'media'
|
||||
LIMIT 1
|
||||
]], { imei })
|
||||
local media = rows[1] and json.decode(rows[1].payload) or nil
|
||||
for _, capture in ipairs(type(media) == "table" and media.captures or {}) do
|
||||
if type(capture) == "table" and type(capture.id) == "string" and photo_gradients[capture.gradient] then
|
||||
owned_media[capture.id] = capture.gradient
|
||||
end
|
||||
end
|
||||
|
||||
local normalized = {}
|
||||
local seen = {}
|
||||
for index, image in ipairs(images) do
|
||||
local media_id = type(image) == "table" and image.id or nil
|
||||
local gradient = media_id and owned_media[media_id] or nil
|
||||
if type(media_id) ~= "string" or #media_id > 64 or not gradient or seen[media_id] then
|
||||
local numeric_id = tonumber(media_id)
|
||||
local normalized_id = numeric_id and tostring(math.floor(numeric_id)) or nil
|
||||
if not numeric_id or numeric_id < 1 or numeric_id ~= math.floor(numeric_id)
|
||||
or seen[normalized_id]
|
||||
then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected unowned Local Pages image from source %s.", tostring(source))
|
||||
return nil
|
||||
end
|
||||
seen[media_id] = true
|
||||
normalized[index] = { id = media_id, gradient = gradient }
|
||||
local url = SkyPhoneMedia.ResolveOwnedMedia(source, normalized_id, "photo")
|
||||
if not url then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected unowned Local Pages image from source %s.", tostring(source))
|
||||
return nil
|
||||
end
|
||||
seen[normalized_id] = true
|
||||
normalized[index] = { id = normalized_id, gradient = ("url(%s)"):format(json.encode(url)) }
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
@@ -189,7 +177,7 @@ Bridge.Callbacks.Register("sky_phone:pages:create", function(source, data)
|
||||
then
|
||||
return { success = false, error = "invalid_post" }
|
||||
end
|
||||
local images = validate_images(source, account.imei, data.images)
|
||||
local images = validate_images(source, data.images)
|
||||
if not images then return { success = false, error = "invalid_images" } end
|
||||
local id = new_id()
|
||||
local statements = {{
|
||||
|
||||
@@ -7,6 +7,15 @@ local sessions = {}
|
||||
local auth_attempts = {}
|
||||
local operation_attempts = {}
|
||||
local max_device_data_bytes = 100000
|
||||
local passcode_pepper = GetConvar(Config.Security.PasscodePepperConvar, "")
|
||||
if passcode_pepper == "" then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Passcode pepper convar '%s' is empty; configure it before production use.",
|
||||
Config.Security.PasscodePepperConvar,
|
||||
{ always = true }
|
||||
)
|
||||
end
|
||||
local allowed_device_namespaces = {
|
||||
settings = true,
|
||||
notifications = true,
|
||||
@@ -14,6 +23,7 @@ local allowed_device_namespaces = {
|
||||
alarms = true,
|
||||
apps = true,
|
||||
games = true,
|
||||
widgets = true,
|
||||
}
|
||||
|
||||
local function trim(value)
|
||||
@@ -233,6 +243,98 @@ local function load_device_data(imei)
|
||||
return data
|
||||
end
|
||||
|
||||
local function load_device_security(imei)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `passcode_length`, `failed_attempts`, `locked_until`
|
||||
FROM `sky_phone_device_security`
|
||||
WHERE `device_imei` = ?
|
||||
LIMIT 1
|
||||
]], { imei })
|
||||
return rows[1]
|
||||
end
|
||||
|
||||
local function security_status(imei)
|
||||
local security = load_device_security(imei)
|
||||
return {
|
||||
enabled = security ~= nil,
|
||||
length = security and tonumber(security.passcode_length) or nil,
|
||||
lockedUntil = security and tonumber(security.locked_until) or 0,
|
||||
}
|
||||
end
|
||||
|
||||
local function valid_passcode(value)
|
||||
return type(value) == "string"
|
||||
and (#value == 4 or #value == 6)
|
||||
and value:match("^%d+$") ~= nil
|
||||
end
|
||||
|
||||
local function passcode_matches(imei, passcode)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT 1 AS `matches`
|
||||
FROM `sky_phone_device_security`
|
||||
WHERE `device_imei` = ?
|
||||
AND `passcode_hash` = UNHEX(SHA2(CONCAT(?, `passcode_salt`, ?), 256))
|
||||
LIMIT 1
|
||||
]], { imei, passcode_pepper, passcode })
|
||||
return rows[1] ~= nil
|
||||
end
|
||||
|
||||
local function verify_passcode(session, passcode)
|
||||
if not valid_passcode(passcode) then
|
||||
return false, { success = false, error = "invalid_passcode" }
|
||||
end
|
||||
|
||||
local security = load_device_security(session.imei)
|
||||
if not security then
|
||||
return false, { success = false, error = "passcode_not_set" }
|
||||
end
|
||||
|
||||
local now = os.time()
|
||||
local locked_until = tonumber(security.locked_until) or 0
|
||||
if locked_until > now then
|
||||
return false, {
|
||||
success = false,
|
||||
error = "passcode_locked",
|
||||
data = { retryAfter = locked_until - now },
|
||||
}
|
||||
end
|
||||
|
||||
if passcode_matches(session.imei, passcode) then
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_device_security`
|
||||
SET `failed_attempts` = 0, `locked_until` = 0
|
||||
WHERE `device_imei` = ?
|
||||
]], { session.imei })
|
||||
return true
|
||||
end
|
||||
|
||||
local failed_attempts = (tonumber(security.failed_attempts) or 0) + 1
|
||||
if failed_attempts >= Config.Security.MaximumAttempts then
|
||||
local next_unlock = now + Config.Security.LockSeconds
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_device_security`
|
||||
SET `failed_attempts` = 0, `locked_until` = ?
|
||||
WHERE `device_imei` = ?
|
||||
]], { next_unlock, session.imei })
|
||||
return false, {
|
||||
success = false,
|
||||
error = "passcode_locked",
|
||||
data = { retryAfter = Config.Security.LockSeconds },
|
||||
}
|
||||
end
|
||||
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_device_security`
|
||||
SET `failed_attempts` = ?
|
||||
WHERE `device_imei` = ?
|
||||
]], { failed_attempts, session.imei })
|
||||
return false, {
|
||||
success = false,
|
||||
error = "invalid_passcode",
|
||||
data = { attemptsRemaining = Config.Security.MaximumAttempts - failed_attempts },
|
||||
}
|
||||
end
|
||||
|
||||
local function account_devices(account_id, current_imei)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `imei`, `device_name`, `created_at`, `updated_at`
|
||||
@@ -247,7 +349,7 @@ local function account_devices(account_id, current_imei)
|
||||
end
|
||||
|
||||
local function bootstrap(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
local session, error_response = SkyPhone.RequireDeviceSession(source)
|
||||
if not session then
|
||||
return nil, error_response
|
||||
end
|
||||
@@ -259,6 +361,7 @@ local function bootstrap(source)
|
||||
|
||||
return {
|
||||
token = session.token,
|
||||
security = security_status(device.imei),
|
||||
device = {
|
||||
imei = device.imei,
|
||||
name = device.device_name,
|
||||
@@ -416,7 +519,7 @@ local function authenticate(source, data, registering)
|
||||
return link_account(source, accounts[1])
|
||||
end
|
||||
|
||||
function SkyPhone.RequireSession(source)
|
||||
function SkyPhone.RequireDeviceSession(source)
|
||||
local session = sessions[source]
|
||||
if not session then
|
||||
return nil, { success = false, error = "device_not_open" }
|
||||
@@ -432,6 +535,17 @@ function SkyPhone.RequireSession(source)
|
||||
return session
|
||||
end
|
||||
|
||||
function SkyPhone.RequireSession(source)
|
||||
local session, error_response = SkyPhone.RequireDeviceSession(source)
|
||||
if not session then
|
||||
return nil, error_response
|
||||
end
|
||||
if not session.unlocked then
|
||||
return nil, { success = false, error = "device_locked" }
|
||||
end
|
||||
return session
|
||||
end
|
||||
|
||||
function SkyPhone.AllowOperation(source, operation, maximum, window_seconds)
|
||||
local now = os.time()
|
||||
operation_attempts[source] = operation_attempts[source] or {}
|
||||
@@ -564,10 +678,12 @@ local function open_phone(source, used_item)
|
||||
return false
|
||||
end
|
||||
|
||||
local security = load_device_security(imei)
|
||||
sessions[source] = {
|
||||
imei = imei,
|
||||
slot = slot.slot,
|
||||
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
|
||||
unlocked = security == nil,
|
||||
}
|
||||
local payload = bootstrap(source)
|
||||
Bridge.Debug(
|
||||
@@ -589,10 +705,12 @@ function SkyPhone.OpenDeviceForCall(source, imei)
|
||||
Bridge.Debug("warn", "[sky_phone] Could not open ringing device %s for source %s.", tostring(imei), tostring(source))
|
||||
return false
|
||||
end
|
||||
local security = load_device_security(imei)
|
||||
sessions[source] = {
|
||||
imei = imei,
|
||||
slot = matches[1].slot,
|
||||
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
|
||||
unlocked = security == nil,
|
||||
}
|
||||
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
|
||||
return true
|
||||
@@ -618,6 +736,106 @@ Bridge.Callbacks.Register("sky_phone:device:close", function(source)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:security:unlock", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "security_unlock", Config.Security.AttemptsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local session, error_response = SkyPhone.RequireDeviceSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
if session.unlocked then
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end
|
||||
|
||||
local verified, verification_error = verify_passcode(session, data and data.passcode)
|
||||
if not verified then
|
||||
return verification_error
|
||||
end
|
||||
session.unlocked = true
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:security:set-passcode", function(source, data)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
local passcode = data and data.passcode
|
||||
if not valid_passcode(passcode) then
|
||||
return { success = false, error = "invalid_passcode" }
|
||||
end
|
||||
if load_device_security(session.imei) then
|
||||
return { success = false, error = "passcode_already_set" }
|
||||
end
|
||||
|
||||
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
|
||||
local salt = salts[1] and salts[1].salt
|
||||
if type(salt) ~= "string" or #salt ~= 32 then
|
||||
error("[sky_phone] Database did not generate a valid passcode salt.")
|
||||
end
|
||||
local result = Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_device_security`
|
||||
(`device_imei`, `passcode_hash`, `passcode_salt`, `passcode_length`)
|
||||
VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?, ?)
|
||||
]], { session.imei, passcode_pepper, salt, passcode, salt, #passcode })
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:security:change-passcode", function(source, data)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
local new_passcode = data and data.newPasscode
|
||||
if not valid_passcode(new_passcode) then
|
||||
return { success = false, error = "invalid_passcode" }
|
||||
end
|
||||
local verified, verification_error = verify_passcode(session, data and data.currentPasscode)
|
||||
if not verified then
|
||||
return verification_error
|
||||
end
|
||||
|
||||
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
|
||||
local salt = salts[1] and salts[1].salt
|
||||
if type(salt) ~= "string" or #salt ~= 32 then
|
||||
error("[sky_phone] Database did not generate a valid passcode salt.")
|
||||
end
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_device_security`
|
||||
SET `passcode_hash` = UNHEX(SHA2(CONCAT(?, ?, ?), 256)),
|
||||
`passcode_salt` = ?, `passcode_length` = ?, `failed_attempts` = 0, `locked_until` = 0
|
||||
WHERE `device_imei` = ?
|
||||
]], { passcode_pepper, salt, new_passcode, salt, #new_passcode, session.imei })
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:security:disable-passcode", function(source, data)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
local verified, verification_error = verify_passcode(session, data and data.passcode)
|
||||
if not verified then
|
||||
return verification_error
|
||||
end
|
||||
local result = Bridge.Database.Query(
|
||||
"DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
|
||||
{ session.imei }
|
||||
)
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
session.unlocked = true
|
||||
return { success = true, data = { security = security_status(session.imei) } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
|
||||
if not Config.Phone.DevelopmentCommand then
|
||||
return { success = false, error = "disabled" }
|
||||
@@ -763,6 +981,10 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||
end
|
||||
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei)
|
||||
if not Bridge.Database.Transaction({
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
|
||||
params = { session.imei },
|
||||
@@ -791,6 +1013,7 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
|
||||
session.unlocked = true
|
||||
refresh_source(source)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
@@ -92,6 +92,18 @@ CREATE TABLE IF NOT EXISTS `sky_phone_device_data` (
|
||||
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_device_security` (
|
||||
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`passcode_hash` BINARY(32) NOT NULL,
|
||||
`passcode_salt` CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`passcode_length` TINYINT UNSIGNED NOT NULL,
|
||||
`failed_attempts` TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`locked_until` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`device_imei`),
|
||||
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_notes` (
|
||||
`id` VARCHAR(64) NOT NULL,
|
||||
`account_id` BIGINT UNSIGNED NULL,
|
||||
|
||||
Reference in New Issue
Block a user