ENH - refine phone apps and FiveM integration

This commit is contained in:
smx.pusha
2026-08-06 18:49:30 +02:00
parent c304f4a9f3
commit 50df7d11c4
31 changed files with 3057 additions and 420 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ An iFruit account is optional. Unlinked devices retain local settings, alarms, m
## Requirements
- 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 metadata inventory: `ox_inventory`, `qb-inventory`, `lj-inventory`, `qs-inventory`, `codem-inventory`, `core_inventory`, or `mf-inventory`. The bridge auto-detects a running provider and normalizes `metadata`/`info`, slots, counts, item mutations, and usable-item callbacks. `mf-inventory` requires ESX.
- 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`.
- 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.
+47 -43
View File
@@ -150,9 +150,35 @@ function hydratePhone(payload: PhoneOpenPayload): void {
void calls.bootstrap()
}
async function hydrateDevelopmentPhone(): Promise<void> {
const response = await nuiCall<PhoneOpenPayload>('development:bootstrap')
if (response.success && response.data) {
hydratePhone(response.data)
return
}
hydratePhone({
account: null,
device: {
data: {},
imei: '356938035643809',
name: 'iFruit Phone',
sim: {
id: 'development-sim',
number: '5551234567',
registered: true,
type: 'registered',
},
},
notes: [],
token: 'development',
})
}
function onMessage(event: MessageEvent<AppMessage>): void {
if (event.data?.type === 'app:open') {
hydratePhone(event.data.data as PhoneOpenPayload)
void nuiCall('ui:opened')
} else if (event.data?.type === 'device:updated') {
hydratePhone(event.data.data as PhoneOpenPayload)
} else if (event.data?.type === 'app:close') {
@@ -193,15 +219,23 @@ function onMessage(event: MessageEvent<AppMessage>): void {
} else if (event.data?.type === 'marketplace:changed' && event.data.data) {
const data = event.data.data as MarketplaceEventData
if (data.counts) marketplace.setCounts(data.counts)
} else if (event.data?.type === 'marketplace:new-message' && event.data.data) {
} else if (
event.data?.type === 'marketplace:new-message' &&
event.data.data
) {
const data = event.data.data as MarketplaceEventData
const notification: PhoneNotificationInput = {
appId: 'citymarkt',
subtitle: data.sender,
text: data.text ?? phone.t('Apps.citymarkt.newMessage', { sender: data.sender ?? '' }),
text:
data.text ??
phone.t('Apps.citymarkt.newMessage', { sender: data.sender ?? '' }),
title: data.title ?? phone.t('Apps.citymarkt.name'),
}
if (data.device && (!phone.isOpen || data.device.imei !== phone.device?.imei)) {
if (
data.device &&
(!phone.isOpen || data.device.imei !== phone.device?.imei)
) {
notification.device = {
imei: data.device.imei,
name: data.device.name,
@@ -226,7 +260,10 @@ function onMessage(event: MessageEvent<AppMessage>): void {
}),
title: data.title ?? phone.t('Apps.calendar.name'),
}
if (data.device && (!phone.isOpen || data.device.imei !== phone.device?.imei)) {
if (
data.device &&
(!phone.isOpen || data.device.imei !== phone.device?.imei)
) {
notification.device = {
imei: data.device.imei,
name: data.device.name,
@@ -318,44 +355,7 @@ onMounted(() => {
}
}, 1000)
if (isDevelopment) {
hydratePhone({
account: {
devices: [
{
created_at: '2026-08-04 12:00:00',
current: true,
device_name: 'iFruit Phone',
imei: '356938035643809',
updated_at: '2026-08-06 12:00:00',
},
],
email: 'demo@ifruit.com',
id: 1,
},
device: {
data: {},
imei: '356938035643809',
name: 'iFruit Phone',
sim: {
id: 'development-sim',
number: '5551234567',
registered: true,
type: 'registered',
},
},
notes: [
{
body: 'Meet Morgan in Vinewood and inspect the Sultan RS.',
createdAt: Date.now() - 3_600_000,
id: 'demo-note-marketplace',
pinned: true,
revision: 1,
title: 'CityMarkt viewing',
updatedAt: Date.now() - 3_600_000,
},
],
token: 'development',
})
void hydrateDevelopmentPhone()
if (new URLSearchParams(window.location.search).has('simPickerPreview')) {
simPicker.value = {
choices: [
@@ -475,7 +475,11 @@ onBeforeUnmount(() => {
<SpringboardView />
<RouterView v-slot="{ Component }">
<Transition :name="appTransitionName">
<component :is="Component" v-if="isAppRoute" :key="route.path" />
<component
:is="Component"
v-if="isAppRoute"
:key="route.path"
/>
</Transition>
</RouterView>
<PhoneHomeIndicator v-if="!isLocked" />
+43 -3
View File
@@ -16,11 +16,15 @@ import {
import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue'
import { useRouter } from 'vue-router'
import { useAccountStore } from '@/stores/account'
import { useCalendarStore } from '@/stores/calendar'
import { usePhoneStore } from '@/stores/phone'
import { useWeatherStore } from '@/stores/weather'
import type { WeatherConditionId } from '@/types/weather'
const phone = usePhoneStore()
const account = useAccountStore()
const calendar = useCalendarStore()
const weather = useWeatherStore()
const router = useRouter()
const now = ref(new Date())
@@ -57,15 +61,46 @@ const date = computed(() =>
}).format(now.value),
)
const day = computed(() => now.value.getDate())
const nextCalendarEvent = computed(() =>
calendar.events
.filter((event) => event.endsAt >= now.value.getTime())
.sort((left, right) => left.startsAt - right.startsAt)
.at(0),
)
const calendarEventLabel = computed(
() =>
nextCalendarEvent.value?.title ?? phone.t('Home.widgets.calendar.event'),
)
async function loadCalendarDay(): Promise<void> {
if (!account.email) {
calendar.events = []
return
}
const start = new Date(now.value)
start.setHours(0, 0, 0, 0)
const end = new Date(start)
end.setDate(end.getDate() + 1)
await calendar.load(start.getTime(), end.getTime())
}
function openWeather(): void {
phone.setLaunchOrigin(null)
void router.push('/apps/weather')
}
function openCalendar(): void {
phone.setLaunchOrigin(null)
void router.push('/apps/calendar')
}
onMounted(() => {
void loadCalendarDay()
intervalId = window.setInterval(() => {
const previousDay = now.value.toDateString()
now.value = new Date()
if (now.value.toDateString() !== previousDay) void loadCalendarDay()
}, 60_000)
})
@@ -91,11 +126,16 @@ onBeforeUnmount(() => {
</button>
<div class="widget-row">
<article class="widget widget--calendar">
<button
type="button"
class="widget widget--calendar"
:aria-label="phone.t('Apps.calendar.name')"
@click="openCalendar"
>
<span>{{ date }}</span>
<strong>{{ day }}</strong>
<small>{{ phone.t('Home.widgets.calendar.event') }}</small>
</article>
<small>{{ calendarEventLabel }}</small>
</button>
<article class="widget widget--battery">
<BatteryCharging :size="25" aria-hidden="true" />
<strong>78%</strong>
@@ -76,4 +76,7 @@ function move(direction: number): void {
<style scoped>
.citymarkt-gallery{position:relative;overflow:hidden;background:#252724}.citymarkt-gallery__image{position:absolute;inset:0;background-position:center!important;background-size:cover!important;transition:background .2s ease}.citymarkt-gallery__empty{position:absolute;inset:0;padding:18px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--muted)}.citymarkt-gallery__empty span{width:48px;height:48px;margin-bottom:8px;border:1px solid #ffffff12;border-radius:16px;display:grid;place-items:center;background:#ffffff08;color:var(--yellow)}.citymarkt-gallery__empty strong{font-size:12px}.citymarkt-gallery__empty small{max-width:190px;margin-top:3px;font-size:8px;line-height:1.35}.citymarkt-gallery__arrow{position:absolute;z-index:2;top:50%;width:31px;height:31px;padding:0;border:1px solid #ffffff24;border-radius:50%;display:grid;place-items:center;background:#11120fba;color:#fff;box-shadow:0 4px 13px #0005;transform:translateY(-50%)}.citymarkt-gallery__arrow--left{left:9px}.citymarkt-gallery__arrow--right{right:9px}.citymarkt-gallery__dots{position:absolute;z-index:2;right:52px;bottom:12px;left:52px;display:flex;justify-content:center;gap:4px}.citymarkt-gallery__dots i{width:4px;height:4px;border-radius:50%;background:#ffffff66;box-shadow:0 1px 3px #0008;transition:width .18s ease,background .18s ease}.citymarkt-gallery__dots i.active{width:11px;border-radius:4px;background:var(--yellow)}.citymarkt-gallery__count{position:absolute;z-index:2;right:9px;bottom:8px;padding:4px 7px;border-radius:8px;background:#11120fc7;color:#fff;font-size:8px;font-weight:800}:global(.citymarkt--light) .citymarkt-gallery--empty{background:#e9eae5}:global(.citymarkt--light) .citymarkt-gallery__empty span{border-color:#00000012;background:#00000008}
.citymarkt-gallery__empty strong{font-size:14px}
.citymarkt-gallery__empty small{margin-top:4px;font-size:11.5px;line-height:1.4}
.citymarkt-gallery__count{font-size:10.5px}
</style>
@@ -101,4 +101,6 @@ onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer
<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}
</style>
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { useGamesStore } from '@/features/games/store'
import { cloneJsonData } from '@/utils/clone'
import {
createMinesweeperGame,
@@ -58,7 +59,7 @@ export const useMinesweeperStore = defineStore('minesweeper', {
? saved.elapsedMs
: 0
this.game = isMinesweeperGameState(saved?.game)
? structuredClone(saved.game)
? cloneJsonData(saved.game)
: null
if (typeof saved?.soundEnabled === 'boolean') {
this.soundEnabled = saved.soundEnabled
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { useGamesStore } from '@/features/games/store'
import { cloneJsonData } from '@/utils/clone'
import {
continueNumberMerge,
@@ -45,7 +46,7 @@ export const useNumberMergeStore = defineStore('number-merge', {
)
this.game = isNumberMergeGameState(saved?.game)
? {
...structuredClone(saved.game),
...cloneJsonData(saved.game),
tiles: saved.game.tiles.map((tile) => ({
...tile,
isNew: false,
+3 -2
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { usePhoneStore } from '@/stores/phone'
import { cloneJsonData } from '@/utils/clone'
export const useGamesStore = defineStore('games', {
state: () => ({
@@ -10,14 +11,14 @@ export const useGamesStore = defineStore('games', {
hydrate(payload: unknown): void {
this.games =
payload && typeof payload === 'object'
? structuredClone(payload as Record<string, unknown>)
? cloneJsonData(payload as Record<string, unknown>)
: {}
},
readGame<T>(gameId: string): T | undefined {
return this.games[gameId] as T | undefined
},
saveGame(gameId: string, payload: unknown): void {
this.games[gameId] = structuredClone(payload)
this.games[gameId] = cloneJsonData(payload)
usePhoneStore().saveDeviceNamespace('games', this.games)
},
},
+4 -3
View File
@@ -10,11 +10,12 @@ import {
parseAlarms,
} from '@/utils/alarms'
import { elapsedMilliseconds, remainingMilliseconds } from '@/utils/clock'
import { cloneJsonData } from '@/utils/clone'
import { usePhoneStore } from '@/stores/phone'
export const useClockStore = defineStore('clock', {
state: () => ({
alarms: structuredClone(DEFAULT_ALARMS),
alarms: cloneJsonData(DEFAULT_ALARMS),
laps: [] as number[],
stopwatchAccumulated: 0,
stopwatchStartedAt: null as number | null,
@@ -27,7 +28,7 @@ export const useClockStore = defineStore('clock', {
actions: {
createAlarm(draft: AlarmDraft): Alarm {
const alarm: Alarm = {
...structuredClone(draft),
...cloneJsonData(draft),
enabled: true,
id: `alarm-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
lastTriggeredMinute: null,
@@ -144,7 +145,7 @@ export const useClockStore = defineStore('clock', {
updateAlarm(id: string, draft: AlarmDraft): void {
const alarm = this.alarms.find((candidate) => candidate.id === id)
if (!alarm) return
Object.assign(alarm, structuredClone(draft), {
Object.assign(alarm, cloneJsonData(draft), {
lastTriggeredMinute: null,
})
this.persistAlarms()
+2 -1
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { nuiCall } from '@/utils/nui'
import { type Note, type NoteDraft } from '@/utils/notes'
import { cloneJsonData } from '@/utils/clone'
export const useNotesStore = defineStore('notes', {
state: () => ({
@@ -29,7 +30,7 @@ export const useNotesStore = defineStore('notes', {
})
},
hydrate(notes: Note[]): void {
this.notes = structuredClone(notes)
this.notes = cloneJsonData(notes)
},
async createRemote(note: Note): Promise<void> {
const response = await nuiCall<Note[]>('notes:create', note)
+17 -2
View File
@@ -4,6 +4,7 @@ import { defineStore } from 'pinia'
import type { AppLaunchOrigin, LaunchablePhoneAppId } from '@/types/apps'
import type { DeviceBootstrap, PhoneDevice } from '@/types/device'
import { clampPage } from '@/utils/pages'
import { cloneJsonData } from '@/utils/clone'
import { nuiCall } from '@/utils/nui'
import {
DEFAULT_PHONE_PREFERENCES,
@@ -417,7 +418,7 @@ const defaultLocales: LocaleTree = {
},
localPages: {
name: 'Local Pages', eyebrow: 'Your city. Your stories.', cityPulse: 'Live from Los Santos',
heroTitle: 'What is happening nearby?', heroBody: 'Places, people, tips and local discoveries from the community.',
heroTitle: 'What is happening nearby?', heroBody: 'Discover places, people and local tips.',
searchPlaceholder: 'Search posts and places', search: 'Search', allCategories: 'All categories',
allLosSantos: 'Los Santos', hoursAgo: '{count}h ago', daysAgo: '{count}d ago',
categories: { recommendation: 'Recommended', wanted: 'Wanted', service: 'Service', event: 'Event', place: 'Place', community: 'Community', citymarkt: 'CityMarkt' },
@@ -524,6 +525,13 @@ const defaultLocales: LocaleTree = {
},
calendar: {
name: 'Calendar',
today: 'Today',
calendars: 'Calendars',
calendar: 'Calendar',
calendarName: 'iFruit',
searchPlaceholder: 'Search events',
previousMonth: 'Previous month',
nextMonth: 'Next month',
eyebrow: 'Your time. Clearly planned.',
schedule: 'Schedule',
event: 'Event',
@@ -545,6 +553,12 @@ const defaultLocales: LocaleTree = {
signInTitle: 'Your iFruit calendar',
signInBody: 'Sign in to iFruit in Settings to sync appointments and receive reminders.',
reminderNotification: 'Upcoming: {title}',
views: {
compact: 'Compact',
stacked: 'Stacked',
details: 'Details',
list: 'List',
},
reminders: {
none: 'No reminder',
atStart: 'At start time',
@@ -869,6 +883,7 @@ const defaultLocales: LocaleTree = {
Common: {
add: 'Add',
cancel: 'Cancel',
clear: 'Clear',
close: 'Close',
back: 'Back',
delete: 'Delete',
@@ -940,7 +955,7 @@ export const usePhoneStore = defineStore('phone', {
lang: 'en',
launchOrigin: null as AppLaunchOrigin | null,
locales: defaultLocales,
preferences: structuredClone(DEFAULT_PHONE_PREFERENCES),
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
}),
getters: {
+1 -1
View File
@@ -19,7 +19,7 @@ export type PagesPost = {
category: PagesCategory
citymarkt_listing_id: string | null
citymarkt_price: number | string | null
created_at: string
created_at: number
district: string | null
id: string
image: string | null
+3 -1
View File
@@ -1,3 +1,5 @@
import { cloneJsonData } from '@/utils/clone'
export const ALARM_SOUND_IDS = [
'radar',
'beacon',
@@ -89,7 +91,7 @@ function readAlarm(value: unknown): Alarm | null {
}
export function parseAlarms(value: unknown): Alarm[] {
if (!Array.isArray(value)) return structuredClone(DEFAULT_ALARMS)
if (!Array.isArray(value)) return cloneJsonData(DEFAULT_ALARMS)
return value.map(readAlarm).filter((alarm): alarm is Alarm => !!alarm)
}
+5
View File
@@ -0,0 +1,5 @@
export function cloneJsonData<T>(value: T): T {
const serialized = JSON.stringify(value)
if (serialized === undefined) return value
return JSON.parse(serialized) as T
}
+5 -4
View File
@@ -1,4 +1,5 @@
import type { LaunchablePhoneAppId } from '@/types/apps'
import { cloneJsonData } from '@/utils/clone'
export const APPEARANCE_MODE_IDS = ['automatic', 'light', 'dark'] as const
export const PHONE_FRAME_IDS = [
@@ -122,7 +123,7 @@ function readNotifications(
Record<LaunchablePhoneAppId, Partial<AppNotificationPreferences>>
>)
: {}
const notifications = structuredClone(DEFAULT_APP_NOTIFICATIONS)
const notifications = cloneJsonData(DEFAULT_APP_NOTIFICATIONS)
for (const appId of Object.keys(notifications) as LaunchablePhoneAppId[]) {
notifications[appId] = {
@@ -141,13 +142,13 @@ function readNotifications(
}
export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
if (!raw) return structuredClone(DEFAULT_PHONE_PREFERENCES)
if (!raw) return cloneJsonData(DEFAULT_PHONE_PREFERENCES)
try {
const parsed = JSON.parse(raw) as Partial<PhonePreferencesV1>
const settings = parsed.settings
if (parsed.version !== 1 || !settings || typeof settings !== 'object') {
return structuredClone(DEFAULT_PHONE_PREFERENCES)
return cloneJsonData(DEFAULT_PHONE_PREFERENCES)
}
const defaults = DEFAULT_PHONE_PREFERENCES.settings
@@ -205,6 +206,6 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 {
version: 1,
}
} catch {
return structuredClone(DEFAULT_PHONE_PREFERENCES)
return cloneJsonData(DEFAULT_PHONE_PREFERENCES)
}
}
File diff suppressed because one or more lines are too long
+35
View File
@@ -910,4 +910,39 @@ onMounted(async () => {
.citymarkt__sell>header strong{font-size:13px}.citymarkt__sell>header small{font-size:10px}.citymarkt__sell>header>button:last-child{font-size:11px}.citymarkt__sell-body h2{font-size:23px;line-height:1.15}.citymarkt__sell-body>p{font-size:11px;line-height:1.45}.citymarkt__sell-body label{font-size:10.5px}.citymarkt__sell-body input:not([type=checkbox]),.citymarkt__sell-body textarea{padding:11px 12px;font-size:12px}.citymarkt__sell-body input:not([type=checkbox]){min-height:41px}.citymarkt__sell-body textarea{line-height:1.4}.citymarkt__switch{font-size:10.5px!important}.citymarkt__previous{font-size:11px}.citymarkt__photo-actions strong{font-size:11px}.citymarkt__photo-actions small{font-size:8.5px;line-height:1.4}.citymarkt__selected-heading strong{font-size:12px}.citymarkt__selected-heading span{font-size:9px}.citymarkt__sell-body h3{font-size:18px}.citymarkt__sell-body>small{font-size:10px}.citymarkt__sell :deep(.citymarkt-select__trigger){height:41px;padding:0 12px;font-size:12px}.citymarkt__sell :deep(.citymarkt-select__menu button){min-height:35px;padding:8px;font-size:11px}.citymarkt__sell :deep(.citymarkt-gallery__empty strong){font-size:13px}.citymarkt__sell :deep(.citymarkt-gallery__empty small){font-size:9px;line-height:1.4}
.citymarkt__field-heading{display:flex;align-items:center;justify-content:space-between;gap:8px}.citymarkt__field-heading>small{color:#ff9c72;font-size:8.5px;font-weight:850;white-space:nowrap;transition:color .18s ease}.citymarkt__field-heading>small.valid{color:#62dc8e}
.citymarkt__pages-share{width:100%;margin:4px 0 8px;padding:10px 12px;border:1px solid #ffc92855;border-radius:12px;display:flex;align-items:center;gap:8px;text-align:left;background:#ffc92816;color:var(--yellow)!important}.citymarkt__pages-share span{flex:1}.citymarkt__pages-share strong,.citymarkt__pages-share small{display:block}.citymarkt__pages-share strong{font-size:10px}.citymarkt__pages-share small{color:var(--muted);font-size:8px}
/* Keep marketplace copy readable at the physical phone scale. */
.citymarkt__brand{font-size:12px}
.citymarkt__categories button{font-size:10.5px}
.citymarkt__filters select,.citymarkt__section-title small{font-size:12px}
.citymarkt__card-image>i,.citymarkt__hero>span,.citymarkt__price-row span,.citymarkt__chat-listing i,.citymarkt__reserve{font-size:10.5px}
.citymarkt__grid button>span:not(.citymarkt__card-image){font-size:13px}
.citymarkt__grid button>small,.citymarkt__preview-price+*+*+small{font-size:11.5px}
.citymarkt__empty span,.citymarkt__auth p,.citymarkt__description{font-size:13px}
.citymarkt__inquiries strong,.citymarkt__list strong{font-size:14px}
.citymarkt__inquiries b,.citymarkt__list b,.citymarkt__profile small,.citymarkt__price-row>small,.citymarkt__seller small,.citymarkt__phone{font-size:11.5px}
.citymarkt__inquiries small,.citymarkt__list small{font-size:11px}
.citymarkt__inquiries i,.citymarkt__tabbar button i,.citymarkt__photo-picker i,.citymarkt__selected-strip button i{font-size:10px}
.citymarkt__tabbar button{font-size:10.5px}
.citymarkt__meta,.citymarkt__inline-auth,.citymarkt__toast{font-size:12px}
.citymarkt__composer textarea,.citymarkt__chat-composer input{font-size:13px}
.citymarkt__composer button,.citymarkt__owner-actions button,.citymarkt__report>div button{font-size:13px}
.citymarkt__sell>header small,.citymarkt__chat>header small,.citymarkt__sell>header>button:last-child{font-size:12px}
.citymarkt__sell-body>p,.citymarkt__sell-body label,.citymarkt__switch,.citymarkt__previous,.citymarkt__sell-body>small{font-size:12px!important}
.citymarkt__sell-body input:not([type=checkbox]),.citymarkt__sell-body textarea,.citymarkt__sell-body select,.citymarkt__report select,.citymarkt__report textarea{font-size:13px}
.citymarkt__chat-listing strong,.citymarkt__chat-listing span,.citymarkt__messages p{font-size:13px}
.citymarkt__messages small{font-size:10.5px}
.citymarkt__segmented button span{font-size:10.5px}
.citymarkt__chat-actions button,.citymarkt__offer-panel>button{font-size:12px}
.citymarkt__offer-panel header small,.citymarkt__offer-panel label{font-size:11.5px}
.citymarkt__offer-panel header strong{font-size:14px}
.citymarkt__image-placeholder small,.citymarkt__photo-actions small,.citymarkt__capture p{font-size:11.5px}
.citymarkt__photo-actions strong,.citymarkt__selected-heading strong{font-size:13px}
.citymarkt__selected-heading span,.citymarkt__photo-source>header small,.citymarkt__photo-source>header>span{font-size:11px}
.citymarkt__field-heading>small{font-size:10.5px}
.citymarkt__pages-share strong{font-size:12.5px}
.citymarkt__pages-share small{font-size:11px}
.citymarkt__sell :deep(.citymarkt-select__trigger){font-size:13px}
.citymarkt__sell :deep(.citymarkt-select__menu button){font-size:12px}
.citymarkt__sell :deep(.citymarkt-gallery__empty small){font-size:11.5px}
</style>
+28 -2
View File
@@ -93,8 +93,8 @@ function label(group: string, value: string): string {
return phone.t(`Apps.localPages.${group}.${value}`)
}
function relativeDate(value: string): string {
const elapsed = Math.max(0, Date.now() - new Date(value.replace(' ', 'T')).getTime())
function relativeDate(value: number): string {
const elapsed = Math.max(0, Date.now() - value)
const hours = Math.max(1, Math.floor(elapsed / 3_600_000))
return hours < 24
? phone.t('Apps.localPages.hoursAgo', { count: String(hours) })
@@ -369,4 +369,30 @@ onMounted(() => void loadFeed())
.pages__post-open{width:100%;padding:0;text-align:left;background:none}.pages__post-foot{gap:6px}.pages__post-foot button{min-height:28px;padding:5px 8px;border-radius:9px;display:flex;align-items:center;gap:4px;background:#ffffff08;color:var(--muted);font-size:9px;font-weight:800}.pages__post-foot button:last-child{margin-left:auto}.pages__post-foot button:first-child.active{background:#ff647318;color:#ff6473}.pages__post-foot button:last-child.active{background:#ffd63e1c;color:var(--yellow)}.pages__post-foot button:disabled{opacity:.55}.pages__post-foot button svg:last-child{margin-left:0}.pages--light .pages__post-foot button{background:#00000008}
.pages__photos{margin-top:5px}.pages__photos>svg{color:var(--yellow)}.pages__photos>h2{margin:7px 0 3px;font-size:19px}.pages__photos>p{margin:0 0 11px;color:var(--muted);font-size:9px;line-height:1.4}.pages__photo-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px}.pages__photo-actions>button{min-width:0;padding:11px 9px;border:1px solid #ffffff12;border-radius:14px;display:flex;flex-direction:column;align-items:flex-start;text-align:left;background:var(--panel)}.pages__photo-actions>button>span{width:34px;height:34px;margin-bottom:8px;border-radius:11px;display:grid;place-items:center;background:#ffd63e1c;color:var(--yellow)}.pages__photo-actions strong{font-size:11px}.pages__photo-actions small{margin-top:2px;color:var(--muted);font-size:8px;line-height:1.35}.pages__selected-heading{margin:15px 1px 7px;display:flex;align-items:center;justify-content:space-between}.pages__selected-heading strong{font-size:12px}.pages__selected-heading span{padding:3px 6px;border-radius:7px;background:var(--panel);color:var(--yellow);font-size:9px;font-weight:900}.pages__selection-gallery{height:142px;border-radius:14px}.pages__selected-strip{margin-top:7px;display:flex;gap:6px;overflow-x:auto;scrollbar-width:none}.pages__selected-strip button{position:relative;width:46px;height:46px;flex:none;border:1px solid #ffffff1d;border-radius:9px;background-position:center!important;background-size:cover!important}.pages__selected-strip button i{position:absolute;left:3px;bottom:3px;width:15px;height:15px;border-radius:50%;display:grid;place-items:center;background:var(--yellow);color:#17191a;font-size:7px;font-style:normal;font-weight:900}.pages__selected-strip button svg{position:absolute;top:3px;right:3px;padding:2px;box-sizing:content-box;border-radius:50%;background:#11120fc7;color:#fff}
.pages__photo-source{position:absolute;z-index:8;inset:47px 0 0;padding:14px 14px 33px;background:#12171b}.pages--light .pages__photo-source{background:#fbfbf6}.pages__photo-source>header{height:52px;display:flex;align-items:center;gap:8px}.pages__photo-source>header>div{min-width:0;flex:1}.pages__photo-source>header small,.pages__photo-source>header strong{display:block}.pages__photo-source>header small{color:var(--yellow);font-size:8px;font-weight:900;text-transform:uppercase}.pages__photo-source>header strong{font-size:18px}.pages__photo-source>header>span{padding:4px 7px;border-radius:8px;background:var(--panel);color:var(--yellow);font-size:8px;font-weight:900}.pages__photo-source>header>button{width:31px;height:31px;padding:0;border-radius:50%;display:grid;place-items:center;background:var(--panel)}.pages__photo-picker{max-height:calc(100% - 58px);display:grid;grid-template-columns:repeat(3,1fr);gap:7px;overflow-y:auto;scrollbar-width:none}.pages__photo-picker button{position:relative;aspect-ratio:1;border:2px solid transparent;border-radius:11px;background-position:center!important;background-size:cover!important}.pages__photo-picker button.active{border-color:var(--yellow)}.pages__photo-picker i{width:19px;height:19px;margin:5px;border-radius:50%;display:grid;place-items:center;background:var(--yellow);color:#17191a;font-size:9px;font-style:normal;font-weight:900}.pages__capture{height:calc(100% - 52px);display:flex;flex-direction:column;align-items:center}.pages__viewfinder{position:relative;width:100%;min-height:305px;overflow:hidden;border-radius:18px;background-position:center!important;background-size:cover!important;box-shadow:inset 0 0 0 1px #ffffff1c}.pages__viewfinder:after{content:'';position:absolute;inset:0;background:linear-gradient(180deg,#0001,#00000038)}.pages__viewfinder>i{position:absolute;z-index:2;width:25px;height:25px;border-color:#fff;border-style:solid}.pages__viewfinder .corner-tl{top:18px;left:18px;border-width:2px 0 0 2px}.pages__viewfinder .corner-tr{top:18px;right:18px;border-width:2px 2px 0 0}.pages__viewfinder .corner-bl{bottom:18px;left:18px;border-width:0 0 2px 2px}.pages__viewfinder .corner-br{right:18px;bottom:18px;border-width:0 2px 2px 0}.pages__camera-flash{position:absolute;z-index:4;inset:0;background:#fff;opacity:0;pointer-events:none;transition:opacity .12s}.pages__camera-flash.active{opacity:.9}.pages__capture p{max-width:230px;margin:9px 0;color:var(--muted);font-size:8px;text-align:center}.pages__shutter{width:58px;height:58px;padding:0;border:5px solid #f5f5ee;border-radius:50%;display:grid;place-items:center;background:var(--yellow);color:#17191a;box-shadow:0 0 0 2px #ffffff42}.pages--light .pages__photo-actions>button,.pages--light .pages__selected-strip button{border-color:#00000012}
/* Keep community content readable at the physical phone scale. */
.pages__header span,.pages__compose header small,.pages__header .pages__brand{font-size:12px}
.pages__hero{padding:13px 15px;gap:12px}.pages__hero div{min-width:0;max-width:205px}.pages__hero small{font-size:9.5px;line-height:1.1}.pages__hero strong{margin:4px 0 3px;font-size:16px;line-height:1.12}.pages__hero span{font-size:11px;line-height:1.3}.pages__hero>svg{flex:none}
.pages__search input{font-size:13px}.pages__search button{font-size:12px}
.pages__post-head strong{font-size:13px}.pages__post-head small{font-size:11.5px}
.pages__post-head i,.pages__author i{font-size:10.5px}
.pages__cover>b,.pages__gallery>span{font-size:10.5px}
.pages__cover--empty,.pages__empty span{font-size:12px}
.pages__post h2{font-size:16px}.pages__post p{font-size:13px}
.pages__post-foot,.pages__post-foot button{font-size:11.5px}
.pages__profile small,.pages__profile b{font-size:11.5px}
.pages__tabbar button{font-size:10.5px}
.pages__author strong{font-size:14px}
.pages__author small,.pages__location small,.pages__market-link small{font-size:11.5px}
.pages__detail article>p{font-size:13px}
.pages__location strong,.pages__market-link strong{font-size:13px}.pages__market-link>b{font-size:14px}
.pages__detail-actions button{font-size:12.5px}
.pages__compose>header strong{font-size:14px}.pages__compose>header>button:last-child{font-size:12px}
.pages__compose-scroll label{font-size:12px}.pages__compose-scroll label>span{font-size:10.5px}
.pages__compose input,.pages__compose textarea{font-size:13px}
.pages__photo-title strong,.pages__gallery-label,.pages__selected-heading strong{font-size:14px}
.pages__photo-title small,.pages__photos>p,.pages__photo-actions small,.pages__capture p{font-size:11.5px}
.pages__photo-title button{font-size:12px}.pages__photo-actions strong{font-size:13px}
.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}
</style>
+12 -12
View File
@@ -288,7 +288,7 @@ onBeforeUnmount(() => {
.memory-header span {
display: block;
color: #8175a2;
font-size: 9px;
font-size: 11px;
font-weight: 800;
letter-spacing: 1.25px;
text-transform: uppercase;
@@ -366,7 +366,7 @@ onBeforeUnmount(() => {
.memory-intro { text-align: center; }
.memory-intro h2 { margin: 0; font-size: 23px; letter-spacing: -0.6px; }
.memory-intro p { margin: 6px 25px 0; color: #7d7397; font-size: 12px; line-height: 1.4; }
.memory-intro p { margin: 7px 18px 0; color: #74698f; font-size: 14px; line-height: 1.4; }
.memory-difficulties {
display: grid;
@@ -374,7 +374,7 @@ onBeforeUnmount(() => {
}
.memory-difficulties button {
min-height: 58px;
min-height: 62px;
display: grid;
grid-template-columns: 48px 1fr auto;
grid-template-rows: 1fr 1fr;
@@ -397,12 +397,12 @@ onBeforeUnmount(() => {
border-radius: 11px;
color: #fff;
background: linear-gradient(135deg, #9a7be1, #7558c4);
font-size: 13px;
font-size: 14px;
font-weight: 800;
}
.memory-difficulty__name { align-self: end; font-size: 13px; font-weight: 800; }
.memory-difficulty__best { align-self: start; color: #8b819f; font-size: 9px; }
.memory-difficulty__name { align-self: end; font-size: 15px; font-weight: 800; }
.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; }
@@ -416,9 +416,9 @@ onBeforeUnmount(() => {
}
.memory-stats div { display: grid; }
.memory-stats span { color: #81769c; font-size: 8px; font-weight: 800; text-transform: uppercase; }
.memory-stats strong { font-size: 18px; line-height: 1.05; }
.memory-stats button { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 11px; font-weight: 800; }
.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 .memory-menu-button {
width: 30px;
height: 30px;
@@ -562,16 +562,16 @@ onBeforeUnmount(() => {
box-shadow: 0 9px 22px rgb(100 75 151 / 17%);
}
.memory-complete > span { color: #8175a2; font-size: 9px; font-weight: 800; letter-spacing: 1px; text-transform: uppercase; }
.memory-complete > span { color: #74698f; font-size: 11px; font-weight: 800; letter-spacing: 1px; text-transform: uppercase; }
.memory-complete h2 { margin: 0; font-size: 27px; }
.memory-complete p { margin: -4px 0 4px; color: #756b8e; font-size: 13px; }
.memory-complete p { margin: -4px 0 4px; color: #756b8e; font-size: 14px; }
.memory-primary,
.memory-secondary {
min-width: 155px;
min-height: 42px;
border-radius: 14px;
font-size: 12px;
font-size: 14px;
font-weight: 800;
}
+46 -41
View File
@@ -431,14 +431,14 @@ onBeforeUnmount(() => {
.neon-menu__copy > span {
display: block;
color: #7cf6e7;
font-size: 8px;
font-size: 14px;
font-weight: 900;
letter-spacing: 1.2px;
text-transform: uppercase;
}
.neon-header h1 {
margin: 1px 0 0;
font-size: 23px;
font-size: 32px;
line-height: 1;
}
.neon-header button,
@@ -531,14 +531,15 @@ onBeforeUnmount(() => {
color: #7a6cff;
}
.neon-menu__copy h2 {
margin: 2px 0 4px;
font-size: 20px;
margin: 4px 0 7px;
font-size: 30px;
}
.neon-menu__copy p {
max-width: 272px;
max-width: 296px;
margin: 0;
color: #b6c0db;
font-size: 10px;
color: #e1e7f5;
font-size: 18px;
font-weight: 550;
line-height: 1.4;
}
.neon-records {
@@ -548,8 +549,8 @@ onBeforeUnmount(() => {
gap: 7px;
}
.neon-records div {
padding: 9px;
border: 1px solid #ffffff12;
padding: 12px 9px;
border: 1px solid #ffffff24;
border-radius: 13px;
background: #ffffff08;
}
@@ -557,20 +558,20 @@ onBeforeUnmount(() => {
.neon-side > span,
.neon-toolbar span {
display: block;
color: #99a8c9;
font-size: 8px;
color: #d2dcef;
font-size: 12px;
font-weight: 850;
text-transform: uppercase;
}
.neon-records strong {
font-size: 18px;
font-size: 26px;
}
.neon-how {
width: 100%;
display: flex;
align-items: center;
gap: 9px;
padding: 9px 11px;
padding: 12px 13px;
border: 1px solid #6ff5e42a;
border-radius: 13px;
color: #72f5e4;
@@ -579,25 +580,25 @@ onBeforeUnmount(() => {
}
.neon-how p {
margin: 0;
color: #aeb9d4;
font-size: 9px;
line-height: 1.35;
color: #dce4f3;
font-size: 16px;
line-height: 1.4;
}
.neon-how strong {
display: block;
color: #fff;
font-size: 10px;
font-size: 17px;
}
.neon-primary,
.neon-secondary {
width: 100%;
min-height: 41px;
min-height: 50px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
border-radius: 13px;
font-size: 11px;
font-size: 18px;
font-weight: 900;
}
.neon-primary {
@@ -626,7 +627,7 @@ onBeforeUnmount(() => {
text-align: center;
}
.neon-toolbar strong {
font-size: 14px;
font-size: 21px;
}
.neon-play-area {
display: flex;
@@ -635,7 +636,7 @@ onBeforeUnmount(() => {
gap: 5px;
}
.neon-board {
width: 230px;
width: 218px;
height: 414px;
display: grid;
grid-template-columns: repeat(10, 1fr);
@@ -699,29 +700,29 @@ onBeforeUnmount(() => {
animation: neon-clear 0.24s ease-out;
}
.neon-side {
width: 47px;
width: 70px;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
padding: 7px 3px 9px;
border: 1px solid #65f4e41f;
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;
}
.neon-side > strong {
font-size: 22px;
font-size: 28px;
}
.neon-preview {
width: 43px;
height: 43px;
width: 62px;
height: 62px;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 1px;
padding: 4px;
border: 1px solid #ffffff16;
gap: 2px;
padding: 5px;
border: 1px solid #ffffff35;
border-radius: 10px;
background: #ffffff08;
}
@@ -733,8 +734,8 @@ onBeforeUnmount(() => {
}
.neon-level {
position: relative;
width: 7px;
height: 246px;
width: 12px;
height: 221px;
overflow: hidden;
border-radius: 8px;
background: #ffffff0c;
@@ -756,7 +757,7 @@ onBeforeUnmount(() => {
margin-top: 9px;
}
.neon-controls button {
height: 41px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
@@ -769,10 +770,14 @@ onBeforeUnmount(() => {
color: #071225;
background: linear-gradient(135deg, #ffe265, #ff9167);
}
.neon-controls svg {
transform: scale(1.2);
}
.neon-hint {
margin: 5px 0 0;
color: #8d9bb9;
font-size: 8px;
margin: 7px 0 0;
color: #d6deed;
font-size: 16px;
font-weight: 700;
text-align: center;
}
.neon-overlay {
@@ -795,19 +800,19 @@ onBeforeUnmount(() => {
}
.neon-overlay > span {
color: #ff7fa0;
font-size: 9px;
font-size: 13px;
font-weight: 900;
letter-spacing: 1px;
text-transform: uppercase;
}
.neon-overlay h2 {
margin: 0;
font-size: 24px;
font-size: 30px;
}
.neon-overlay p {
margin: 0 0 4px;
color: #aeb8d0;
font-size: 10px;
color: #dce4f2;
font-size: 16px;
}
@keyframes neon-fall {
0% {
+17 -17
View File
@@ -370,7 +370,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
.number-merge-header span {
display: block;
color: #9c7465;
font-size: 9px;
font-size: 11px;
font-weight: 800;
letter-spacing: 1.2px;
text-transform: uppercase;
@@ -435,8 +435,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
.number-merge-hero__tile--4 { color: #fff7e4; background: #e96c2c; }
.number-merge-hero__tile--8 { color: #fff7e4; background: #713c31; }
.number-merge-menu__intro h2 { margin: 0; font-size: 22px; letter-spacing: -0.5px; }
.number-merge-menu__intro p { max-width: 270px; margin: 5px 0 0; color: #8b675b; font-size: 11px; line-height: 1.4; }
.number-merge-menu__intro h2 { margin: 0; font-size: 25px; letter-spacing: -0.5px; }
.number-merge-menu__intro p { max-width: 300px; margin: 6px 0 0; color: #8b675b; font-size: 14px; line-height: 1.45; }
.number-merge-records {
width: 100%;
@@ -457,22 +457,22 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
.number-merge-records span,
.number-merge-toolbar span {
color: #9d7567;
font-size: 8px;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.number-merge-records strong { font-size: 17px; }
.number-merge-records strong { font-size: 20px; }
.number-merge-menu__actions { width: 100%; display: grid; gap: 7px; }
.number-merge-primary,
.number-merge-secondary,
.number-merge-danger {
min-height: 42px;
min-height: 46px;
padding: 0 18px;
border-radius: 14px;
font-size: 12px;
font-size: 15px;
font-weight: 850;
cursor: pointer;
}
@@ -497,16 +497,16 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
background: rgb(255 255 255 / 28%);
}
.number-merge-how-to strong { font-size: 10px; text-transform: uppercase; }
.number-merge-how-to p { margin: 3px 0 0; font-size: 9px; line-height: 1.35; }
.number-merge-how-to strong { font-size: 13px; text-transform: uppercase; }
.number-merge-how-to p { margin: 5px 0 0; font-size: 12px; line-height: 1.4; }
.number-merge-how-to div {
margin: 6px 0 3px;
margin: 7px 0 4px;
color: #a7472d;
font-size: 11px;
font-size: 14px;
font-weight: 900;
letter-spacing: -0.2px;
}
.number-merge-how-to small { display: block; color: #8b6559; font-size: 8px; line-height: 1.25; }
.number-merge-how-to small { display: block; color: #8b6559; font-size: 11px; line-height: 1.35; }
.number-merge-game { padding-top: 5px; }
@@ -525,7 +525,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
line-height: 1.05;
}
.number-merge-toolbar strong { max-width: 80px; overflow: hidden; font-size: 16px; text-overflow: ellipsis; }
.number-merge-toolbar strong { max-width: 80px; overflow: hidden; font-size: 18px; text-overflow: ellipsis; }
.number-merge-toolbar__restart { justify-self: end; }
.number-merge-board {
@@ -625,7 +625,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
.number-merge-overlay > span { color: #ffc75b; font-size: 29px; font-weight: 900; }
.number-merge-overlay h2 { margin: 0; font-size: 25px; }
.number-merge-overlay p { margin: -3px 0 4px; color: #e7cabd; font-size: 11px; line-height: 1.35; }
.number-merge-overlay p { margin: -3px 0 4px; color: #e7cabd; font-size: 14px; line-height: 1.4; }
.number-merge-overlay button { min-width: 160px; }
.number-merge-overlay .number-merge-secondary { color: #fff1df; background: rgb(255 255 255 / 8%); border-color: rgb(255 255 255 / 13%); }
@@ -634,11 +634,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
border: 0;
color: #e9cec2;
background: transparent;
font-size: 10px;
font-size: 13px;
font-weight: 800;
}
.number-merge-hint { margin: 10px 0 7px; color: #976f61; font-size: 9px; text-align: center; }
.number-merge-hint { margin: 10px 0 7px; color: #976f61; font-size: 12px; text-align: center; }
.number-merge-controls {
display: grid;
@@ -683,7 +683,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
}
.number-merge-confirm h2 { margin: 0; font-size: 21px; }
.number-merge-confirm p { margin: 0 0 5px; color: #876359; font-size: 11px; line-height: 1.4; }
.number-merge-confirm p { margin: 0 0 5px; color: #876359; font-size: 14px; line-height: 1.4; }
.number-merge-danger { border: 0; color: #fff; background: #b64f39; }
button:active { transform: scale(0.97); }
+26 -3
View File
@@ -159,10 +159,33 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.flappy-app { --sky-a:#53c9ed;--sky-b:#7970df;--tower:#645bd1; 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--neon { --sky-a:#1b1e58;--sky-b:#7b258c;--tower:#23cbd1; }.flappy-app--storm { --sky-a:#526779;--sky-b:#24354e;--tower:#b06f64; }
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#c3d7ef;font-size:9px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:0;font-size:24px;line-height:1}.flappy-header button,.flappy-toolbar button{width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff1c;border-radius:12px;color:#fff;background:#ffffff12}
.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--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-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}
.flappy-menu__copy h2{margin:4px 0 7px;font-size:30px}
.flappy-menu__copy p{max-width:295px;color:#e4ebf7;font-size:18px;font-weight:550;line-height:1.4}
.flappy-record{padding:12px 14px;border-color:#ffffff2b;background:#ffffff12}
.flappy-record span{color:#d9e4f3;font-size:15px;font-weight:850}
.flappy-record strong{font-size:26px}
.flappy-designs button{gap:5px;padding:9px 3px;border-color:#ffffff25;color:#edf3ff;background:#ffffff0e;font-size:14px;font-weight:750}
.flappy-designs button.active{background:#ffffff24}
.flappy-designs i{width:32px;height:15px}
.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-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}
.flappy-horizon{background:var(--tower-dark);box-shadow:0 -5px 18px var(--tower-glow)}
.flappy-overlay>span{color:#ffc09b;font-size:13px}
.flappy-overlay h2{font-size:30px}
@keyframes flappy-hero{from{transform:translateY(7px) rotate(-5deg)}to{transform:translateY(-8px) rotate(7deg)}}@keyframes flappy-trail{0%,100%{opacity:.18;transform:scaleX(.65)}50%{opacity:.62;transform:scaleX(1)}}@keyframes flappy-wing{0%{filter:brightness(1.12) drop-shadow(0 4px 5px #071d3e66);scale:1.04}100%{filter:brightness(1) drop-shadow(0 4px 5px #071d3e66);scale:1}}@keyframes flappy-bird-flap{0%{transform:rotate(13deg) scaleY(.92)}42%{transform:rotate(-42deg) scaleY(.84)}100%{transform:rotate(8deg)}}@keyframes flappy-bird-far-flap{0%{transform:rotate(5deg)}45%{transform:rotate(-27deg)}100%{transform:rotate(3deg)}}@keyframes flappy-bird-glide{0%,100%{transform:rotate(7deg)}50%{transform:rotate(-32deg) scaleY(.88)}}@keyframes flappy-bird-far-glide{0%,100%{transform:rotate(2deg)}50%{transform:rotate(-20deg)}}@keyframes cloud-drift{from{transform:translate3d(40px,0,0) scale(var(--cloud-scale))}to{transform:translate3d(-440px,0,0) scale(var(--cloud-scale))}}@keyframes flappy-crash{0%,100%{transform:translate(0)}25%{transform:translate(-5px,3px)}50%{transform:translate(5px,-2px)}75%{transform:translate(-3px,1px)}}button:active{transform:scale(.97)}@media(prefers-reduced-motion:reduce){.sky-bird,.sky-bird :deep(*),.flappy-menu__trail,.flappy-clouds i,.flappy-stage{animation:none}}
</style>
+27 -26
View File
@@ -37,12 +37,12 @@ let previousFrameTime = 0
let effectTimer: ReturnType<typeof setTimeout> | undefined
const blockColors = [
'#ff6b68',
'#ffbf3f',
'#39c9d3',
'#8c63e8',
'#ff8454',
'#54d5a5',
'#ff4d67',
'#ffd23f',
'#27dff2',
'#a879ff',
'#ff7a3d',
'#36e6a0',
]
function blockStyle(block: TowerBlock, index: number): CSSProperties {
@@ -306,51 +306,52 @@ 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: #a69dd3; font-size: 9px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
.tower-header h1 { margin: 0; font-size: 24px; line-height: 1; letter-spacing: -0.8px; }
.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-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: 13px; text-align: center; }
.tower-menu__preview { position: relative; width: 190px; height: 235px; }
.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: #ffbd45; font-size: 9px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase; }
.tower-menu__copy h2 { margin: 3px 0 5px; font-size: 21px; }
.tower-menu__copy p { max-width: 275px; margin: 0; color: #aca7cc; font-size: 10px; line-height: 1.4; }
.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-records { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.tower-records div { display: grid; gap: 2px; padding: 9px; border: 1px solid #ffffff0e; border-radius: 13px; background: #ffffff0a; }
.tower-records span { color: #9790bd; font-size: 8px; font-weight: 800; text-transform: uppercase; }
.tower-records strong { font-size: 18px; }
.tower-primary, .tower-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; }
.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-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: #8d87b3; font-size: 9px; }
.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: #918ab9; font-size: 8px; font-weight: 850; text-transform: uppercase; }
.tower-toolbar strong { font-size: 16px; }
.tower-stage { position: relative; width: 100%; height: 500px; display: block; overflow: hidden; padding: 0; border: 1px solid #ffffff12; border-radius: 22px; background: linear-gradient(#17173b, #322361 60%, #70426c); box-shadow: inset 0 0 35px #08091d99, 0 16px 28px #08091c66; touch-action: manipulation; }
.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-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%; }
.tower-stack { position: absolute; inset: 0 14px; }
.tower-block { position: absolute; height: 31px; border-radius: 7px; background: linear-gradient(180deg, color-mix(in srgb, var(--tower-block-color), white 18%), var(--tower-block-color)); box-shadow: inset 0 4px 0 #ffffff35, inset 0 -4px 0 #00000016, 0 7px 10px #08091d55; }
.tower-block--active { z-index: 3; box-shadow: inset 0 4px 0 #ffffff45, 0 0 18px color-mix(in srgb, var(--tower-block-color), transparent 45%); }
.tower-block { position: absolute; height: 32px; border: 1px solid #ffffff80; border-radius: 7px; background: var(--tower-block-color); box-shadow: inset 0 5px 0 #ffffff52, inset 0 -4px 0 #00000024, 0 7px 12px #08091d80; }
.tower-block--active { z-index: 3; border: 2px solid #fff; filter: saturate(1.3) brightness(1.2); box-shadow: inset 0 5px 0 #ffffff70, 0 0 8px #fff, 0 0 24px var(--tower-block-color), 0 9px 14px #08091d90; animation: tower-active-pulse .7s ease-in-out infinite alternate; }
.tower-block--placed { animation: tower-land 180ms ease-out; }
.tower-block--falling { z-index: 4; animation: tower-fall 850ms ease-in forwards; }
.tower-ground { position: absolute; right: 0; bottom: 0; left: 0; height: 35px; background: linear-gradient(#31224f, #16152e); box-shadow: 0 -8px 20px #a9547928; }
.tower-ground { position: absolute; right: 0; bottom: 0; left: 0; height: 35px; background: linear-gradient(#5b3468, #191630); box-shadow: 0 -8px 24px #ff729150; }
.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-overlay > svg { color: #ffbd4d; }
.tower-overlay > span { color: #ff9b65; font-size: 9px; font-weight: 900; letter-spacing: 1.2px; text-transform: uppercase; }
.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: #a9a3c9; font-size: 10px; }
.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-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); } }
@keyframes tower-perfect-pop { 0% { opacity: 0; transform: translateX(-50%) scale(.4); } 35% { opacity: 1; transform: translateX(-50%) scale(1.2); } 100% { opacity: 0; transform: translateX(-50%) scale(1); } }
@keyframes tower-perfect-glow { 0%, 100% { box-shadow: inset 0 0 35px #08091d99, 0 16px 28px #08091c66; } 45% { box-shadow: inset 0 0 45px #ffd75c55, 0 0 30px #ffd75c66; } }
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
Config.Bridge = {
Framework = "auto", -- auto, esx, qbox, qb
Inventory = "auto", -- auto, ox, qb, lj, qs, codem, core, mf
Inventory = "auto", -- auto, ox, qb, lj, qs, codem, core, mf, smx
Locale = "en",
CallbackTimeout = 5000,
Debug = false,
+25 -3
View File
@@ -15,7 +15,7 @@ Locales["en"] = {
},
Nui = {
Common = {
add = "Add", back = "Back", cancel = "Cancel", close = "Close", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause",
add = "Add", back = "Back", cancel = "Cancel", clear = "Clear", close = "Close", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause",
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop",
},
@@ -168,7 +168,29 @@ Locales["en"] = {
},
},
calendar = {
name = "Calendar", reminder = "Upcoming: {title}",
name = "Calendar", today = "Today", calendars = "Calendars", calendar = "Calendar", calendarName = "iFruit",
searchPlaceholder = "Search events", previousMonth = "Previous month", nextMonth = "Next month",
eyebrow = "Your time. Clearly planned.", schedule = "Schedule", event = "Event", appointment = "Appointment",
newEvent = "New event", editEvent = "Edit event", deleteEvent = "Delete event", details = "Event details",
title = "Title", titlePlaceholder = "What is planned?", date = "Date", starts = "Starts", ends = "Ends",
reminder = "Reminder", reminderNotification = "Upcoming: {title}", note = "Note",
notePlaceholder = "Add details, an address or anything to remember...", noEvents = "Nothing planned",
noEventsBody = "This day is still free. Add an event whenever you are ready.",
signInTitle = "Your iFruit calendar",
signInBody = "Sign in to iFruit in Settings to sync appointments and receive reminders.",
views = { compact = "Compact", stacked = "Stacked", details = "Details", list = "List" },
reminders = {
none = "No reminder", atStart = "At start time", tenMinutes = "10 minutes before",
thirtyMinutes = "30 minutes before", oneHour = "1 hour before", oneDay = "1 day before",
},
errors = {
invalid_event = "Enter a title and make sure the end is after the start.",
invalid_range = "This calendar period is invalid.",
conflict = "This event changed on another phone. Open it again.",
rate_limited = "Too many changes. Try again shortly.",
not_authenticated = "Sign in to your iFruit account first.",
request_failed = "Calendar is temporarily unavailable.", default = "The calendar request failed.",
},
},
clock = {
name = "Clock", lap = "Lap", minutes = "Minutes", add = "Add alarm", location = "Los Santos",
@@ -281,7 +303,7 @@ Locales["en"] = {
},
localPages = {
name = "Local Pages", eyebrow = "Your city. Your stories.", cityPulse = "Live from Los Santos",
heroTitle = "What is happening nearby?", heroBody = "Places, people, tips and local discoveries from the community.",
heroTitle = "What is happening nearby?", heroBody = "Discover places, people and local tips.",
searchPlaceholder = "Search posts and places", search = "Search", allCategories = "All categories",
allLosSantos = "Los Santos", hoursAgo = "{count}h ago", daysAgo = "{count}d ago",
categories = { recommendation = "Recommended", wanted = "Wanted", service = "Service", event = "Event", place = "Place", community = "Community", citymarkt = "CityMarkt" },
@@ -15,6 +15,8 @@ if configured_inventory == "auto" then
configured_inventory = "core"
elseif GetResourceState("mf-inventory") == "started" then
configured_inventory = "mf"
elseif GetResourceState("smx-inventory") == "started" then
configured_inventory = "smx"
end
end
@@ -26,6 +28,7 @@ local supported_inventories = {
codem = true,
core = true,
mf = true,
smx = true,
}
if not supported_inventories[configured_inventory] then
@@ -0,0 +1,145 @@
if Bridge.Inventory.Name ~= "smx" then
return
end
if Bridge.Framework.GetName() ~= "esx" then
error("[sky_phone] smx-inventory is only supported with ESX.")
end
local ESX = exports["es_extended"]:getSharedObject()
local metadata_key = "sky_phone_inventory"
ESX.AddItems({
{ name = Config.Phone.Item, label = Config.Phone.DeviceName, weight = 100, rare = false, canRemove = true },
{ name = Config.Sim.RegisteredItem, label = "Registered SIM", weight = 5, rare = false, canRemove = true },
{ name = Config.Sim.AnonymousItem, label = "Anonymous SIM", weight = 5, rare = false, canRemove = true },
})
local function get_player(source)
return ESX.GetPlayerFromId(source)
end
local function get_item(source, item_name)
local player = get_player(source)
local item = player and player.getInventoryItem(item_name) or nil
if not item or (tonumber(item.count) or 0) < 1 then
return nil
end
local player_metadata = player.getMeta()
local all_metadata = type(player_metadata) == "table" and player_metadata[metadata_key] or nil
local metadata = type(all_metadata) == "table" and all_metadata[item_name] or nil
return {
name = item_name,
slot = item_name,
count = 1,
amount = 1,
metadata = type(metadata) == "table" and metadata or {},
}
end
local function set_item_metadata(source, item_name, metadata)
local player = get_player(source)
if not player or not get_item(source, item_name) then
return false
end
local player_metadata = player.getMeta()
local all_metadata = type(player_metadata) == "table" and player_metadata[metadata_key] or nil
all_metadata = type(all_metadata) == "table" and all_metadata or {}
all_metadata[item_name] = metadata or {}
player.setMeta(metadata_key, all_metadata)
return true
end
local function clear_item_metadata(player, item_name)
local player_metadata = player.getMeta()
local all_metadata = type(player_metadata) == "table" and player_metadata[metadata_key] or nil
if type(all_metadata) ~= "table" or all_metadata[item_name] == nil then
return
end
all_metadata[item_name] = nil
player.setMeta(metadata_key, all_metadata)
end
function Bridge.Inventory.GetResourceName()
return "smx-inventory"
end
function Bridge.Inventory.GetSlot(source, slot_id)
return get_item(source, tostring(slot_id))
end
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
local item = get_item(source, item_name)
if not item or not Bridge.Inventory.MetadataMatches(item.metadata, metadata) then
return {}
end
return { item }
end
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
return set_item_metadata(source, tostring(slot_id), metadata)
end
function Bridge.Inventory.CanCarryItem(source, item_name, count)
local player = get_player(source)
return player and player.canCarryItem(item_name, count) or false
end
function Bridge.Inventory.AddItem(source, item_name, count, _, metadata)
local player = get_player(source)
if not player or not player.canCarryItem(item_name, count) then
return false
end
local before = player.getInventoryItem(item_name)
local before_count = before and tonumber(before.count) or 0
player.addInventoryItem(item_name, count)
local after = player.getInventoryItem(item_name)
local added = (after and tonumber(after.count) or 0) - before_count
if added ~= count then
return false
end
if metadata and not set_item_metadata(source, item_name, metadata) then
player.removeInventoryItem(item_name, count)
return false
end
return true
end
function Bridge.Inventory.RemoveItem(source, item_name, count)
local player = get_player(source)
local before = player and player.getInventoryItem(item_name) or nil
local before_count = before and tonumber(before.count) or 0
if not player or before_count < count then
return 0
end
player.removeInventoryItem(item_name, count)
local after = player.getInventoryItem(item_name)
local removed = before_count - (after and tonumber(after.count) or 0)
if removed == count then
clear_item_metadata(player, item_name)
end
return removed
end
function Bridge.Inventory.RegisterUsableItem(item_name, callback)
ESX.RegisterUsableItem(item_name, function(source)
local item = get_item(source, item_name)
if not item then
Bridge.Debug(
"warn",
"[sky_phone] smx-inventory usable callback could not resolve item '%s' for source %s.",
item_name,
tostring(source)
)
return
end
callback(source, item)
end)
return true
end
+23 -4
View File
@@ -1,4 +1,5 @@
local is_open = false
local open_requested = false
local notification_focus = false
local device_payload = nil
local sim_picker_open = false
@@ -96,13 +97,11 @@ local function open_phone()
return
end
is_open = true
notification_focus = false
SetNuiFocus(true, true)
send_open_message()
end
local function close_phone()
open_requested = false
if not is_open then
return
end
@@ -149,13 +148,31 @@ if Config.Phone.DevelopmentCommand then
end
RegisterNUICallback("ui:ready", function(_, cb)
if is_open then
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
if open_requested and device_payload then
send_open_message()
end
cb({ success = true })
end)
RegisterNUICallback("ui:opened", function(_, cb)
if not open_requested or not device_payload then
Bridge.Debug(
"warn",
"[sky_phone] Ignored a NUI open confirmation without a pending device open.",
{ always = true }
)
cb({ success = false, error = "open_not_requested" })
return
end
is_open = true
notification_focus = false
SetNuiFocus(true, true)
cb({ success = true })
end)
RegisterNUICallback("close", function(_, cb)
close_phone()
cb({ success = true })
@@ -257,6 +274,7 @@ RegisterNetEvent("sky_phone:device:open", function(data)
{ always = true }
)
device_payload = data
open_requested = true
open_phone()
end)
@@ -266,6 +284,7 @@ RegisterNetEvent("sky_phone:device:updated", function(data)
end)
RegisterNetEvent("sky_phone:device:invalidated", function()
open_requested = false
device_payload = nil
close_phone()
end)
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-Dj6qNtrv.js"></script>
<script type="module" crossorigin src="./assets/sky-index-C2xxN8xi.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-DnowIFpD.css">
</head>
<body>
+7 -1
View File
@@ -72,6 +72,12 @@ end
local function hydrate_posts(rows)
for _, post in ipairs(rows) do
local created_at = tonumber(post.created_at_unix)
if not created_at then
error("[sky_phone] Local Pages post has an invalid created_at timestamp.")
end
post.created_at = created_at * 1000
post.created_at_unix = nil
post.images = load_images(post.id)
post.like_count = tonumber(post.like_count) or 0
post.is_liked = tonumber(post.is_liked) or 0
@@ -88,7 +94,7 @@ local function list_posts(account_id, where_clause, values, limit, offset)
parameters[#parameters + 1] = offset
return hydrate_posts(Bridge.Database.Query(([[
SELECT p.`id`, p.`title`, p.`body`, p.`category`, p.`district`, p.`source_type`,
p.`citymarkt_listing_id`, p.`created_at`,
p.`citymarkt_listing_id`, UNIX_TIMESTAMP(p.`created_at`) AS `created_at_unix`,
SUBSTRING_INDEX(a.`email`, '@', 1) AS `author_name`,
(p.`account_id` = ?) AS `is_owner`,
EXISTS(SELECT 1 FROM `sky_phone_pages_reactions` r WHERE r.`post_id` = p.`id`