Merge branch 'benja/app-overhaul' into dev

This commit is contained in:
smx.pusha
2026-08-08 21:53:17 +02:00
31 changed files with 4709 additions and 1314 deletions
+3 -3
View File
@@ -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"`.
@@ -49,7 +49,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,
+1555 -278
View File
File diff suppressed because it is too large Load Diff
+26 -3
View File
@@ -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'
@@ -39,6 +39,7 @@ 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 })
const dragStyle = computed(() =>
isDragging.value
@@ -49,6 +50,7 @@ const dragStyle = computed(() =>
)
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 +62,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) {
@@ -167,8 +175,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 +216,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"
@@ -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>
+1 -1
View File
@@ -118,7 +118,7 @@ describe('app registry', () => {
PHONE_APPS.filter((app) => app.category === 'social').map(
(app) => app.id,
),
).toEqual(['local-pages', 'phone', 'darkchat', 'mail'])
).toEqual(['local-pages', 'phone', 'darkchat', 'banking', 'mail'])
expect(
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
@@ -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: [
+1 -1
View File
@@ -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 },
+27
View File
@@ -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: [],
})
})
})
+39 -3
View File
@@ -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
},
},
})
+2
View File
@@ -744,6 +744,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',
+9 -8
View File
@@ -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'
File diff suppressed because it is too large Load Diff
+28 -2
View File
@@ -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
@@ -413,7 +418,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 +504,7 @@ onBeforeUnmount(() => {
router.push({
path: '/apps/photos',
query: requestedMessageMedia
? { messageAttachment: requestedMessageMedia }
? { mediaAttachment: requestedMessageMedia }
: undefined,
})
"
@@ -671,6 +686,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
+61 -1
View File
@@ -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%;
}
+387 -68
View File
@@ -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>
+52 -27
View File
@@ -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;
+78 -19
View File
@@ -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); }
+97 -117
View File
@@ -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;
+68 -56
View File
@@ -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 {
+18 -6
View File
@@ -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}
+91 -106
View File
@@ -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);
}
+41 -29
View File
@@ -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); } }
+1 -1
View File
@@ -15,7 +15,7 @@ Config.Command = "phone"
Config.Phone = {
Item = "phone",
DevelopmentCommand = true,
DevelopmentCommand = false,
DeviceName = "iFruit Phone",
}
+3 -3
View File
@@ -213,7 +213,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",
@@ -239,7 +239,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!",
},
@@ -408,7 +408,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",
+10 -2
View File
@@ -436,7 +436,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 +619,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",
@@ -841,6 +841,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 })
+18 -28
View File
@@ -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
+14 -26
View File
@@ -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 = {{