mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 09:18:57 +00:00
Merge branch 'benja/app-overhaul' into dev
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# sky_phone
|
||||
|
||||
Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `sky_phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly.
|
||||
Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly.
|
||||
|
||||
An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, notes, contacts, and recent calls. Linking from Mail or Settings moves local data into an empty cloud account; an existing cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting cloud data.
|
||||
|
||||
@@ -8,7 +8,7 @@ An iFruit account is optional. Unlinked devices retain local settings, alarms, m
|
||||
|
||||
- ESX Legacy (`es_extended`), Qbox (`qbx_core`), or QBCore (`qb-core`). The bridge selects a running supported framework when `Config.Bridge.Framework` is set to `"auto"`.
|
||||
- A supported inventory: `ox_inventory`, `qb-inventory`, `lj-inventory`, `qs-inventory`, `codem-inventory`, `core_inventory`, `mf-inventory`, or `smx-inventory`. The bridge auto-detects a running provider and normalizes metadata, slots, counts, item mutations, and usable-item callbacks. `mf-inventory` and `smx-inventory` require ESX. Because SMX stores standard ESX items as stacks, its adapter persists one active Phone/SIM metadata record per player and item type in ESX player metadata.
|
||||
- A non-stackable inventory item named `sky_phone`.
|
||||
- A non-stackable inventory item named `phone`.
|
||||
- Two unique, non-stackable inventory items named `sky_phone_sim_registered` and `sky_phone_sim_anonymous`. Their metadata is initialized automatically on first use, so shops and crafting recipes add plain items without supplying a number.
|
||||
- `oxmysql` with MySQL/MariaDB.
|
||||
- `pma-voice` when `Config.Calls.VoiceProvider` is set to `"pma"`.
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { kBadge } from 'konsta/vue'
|
||||
import { Minus } from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -31,4 +31,31 @@ describe('message media handoff', () => {
|
||||
expect(store.request).toMatchObject({ mediaType: 'video', target: '4205550196' })
|
||||
expect(store.cancel()).toBe('/apps/messages')
|
||||
})
|
||||
|
||||
it('returns multiple photos and the requesting app context', () => {
|
||||
const store = useMessageMediaStore()
|
||||
const secondPhoto = { ...photo, id: 18 }
|
||||
store.begin('citymarkt:sell', 'photo', '/apps/citymarkt?sell=1', 2, {
|
||||
title: 'Draft listing',
|
||||
})
|
||||
|
||||
expect(store.completeMany([photo, secondPhoto])).toBe('/apps/citymarkt?sell=1')
|
||||
expect(store.consumeMany<{ title: string }>('citymarkt:sell')).toEqual({
|
||||
context: { title: 'Draft listing' },
|
||||
media: [photo, secondPhoto],
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the requesting app context when selection is cancelled', () => {
|
||||
const store = useMessageMediaStore()
|
||||
store.begin('local-pages:compose', 'photo', '/apps/local-pages?compose=1', 6, {
|
||||
title: 'Draft post',
|
||||
})
|
||||
|
||||
expect(store.cancel()).toBe('/apps/local-pages?compose=1')
|
||||
expect(store.consumeMany<{ title: string }>('local-pages:compose')).toEqual({
|
||||
context: { title: 'Draft post' },
|
||||
media: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,13 +3,20 @@ import { defineStore } from 'pinia'
|
||||
import type { MediaType, PhoneMedia } from '@/types/media'
|
||||
|
||||
type MessageMediaRequest = {
|
||||
context?: unknown
|
||||
maxSelection: number
|
||||
mediaType: MediaType
|
||||
returnPath: string
|
||||
target: string
|
||||
}
|
||||
|
||||
type MessageMediaResult = MessageMediaRequest & {
|
||||
media: PhoneMedia
|
||||
media: PhoneMedia[]
|
||||
}
|
||||
|
||||
export type MediaSelectionResult<T = unknown> = {
|
||||
context?: T
|
||||
media: PhoneMedia[]
|
||||
}
|
||||
|
||||
export const useMessageMediaStore = defineStore('message-media', {
|
||||
@@ -22,17 +29,37 @@ export const useMessageMediaStore = defineStore('message-media', {
|
||||
target: string,
|
||||
mediaType: MediaType,
|
||||
returnPath = '/apps/messages',
|
||||
maxSelection = 1,
|
||||
context?: unknown,
|
||||
): void {
|
||||
this.request = { mediaType, returnPath, target }
|
||||
this.request = {
|
||||
context,
|
||||
maxSelection: Math.max(1, Math.floor(maxSelection)),
|
||||
mediaType,
|
||||
returnPath,
|
||||
target,
|
||||
}
|
||||
this.result = null
|
||||
},
|
||||
cancel(): string {
|
||||
const returnPath = this.request?.returnPath ?? '/apps/messages'
|
||||
if (this.request) this.result = { ...this.request, media: [] }
|
||||
this.request = null
|
||||
return returnPath
|
||||
},
|
||||
complete(media: PhoneMedia): string | null {
|
||||
if (!this.request || this.request.mediaType !== media.mediaType) return null
|
||||
return this.completeMany([media])
|
||||
},
|
||||
completeMany(media: PhoneMedia[]): string | null {
|
||||
if (
|
||||
!this.request ||
|
||||
media.length < 1 ||
|
||||
media.length > this.request.maxSelection ||
|
||||
media.some((entry) => entry.mediaType !== this.request?.mediaType)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const returnPath = this.request.returnPath
|
||||
this.result = { ...this.request, media }
|
||||
this.request = null
|
||||
@@ -40,9 +67,18 @@ export const useMessageMediaStore = defineStore('message-media', {
|
||||
},
|
||||
consume(target: string): PhoneMedia | null {
|
||||
if (!this.result || this.result.target !== target) return null
|
||||
const media = this.result.media
|
||||
const media = this.result.media[0] ?? null
|
||||
this.result = null
|
||||
return media
|
||||
},
|
||||
consumeMany<T = unknown>(target: string): MediaSelectionResult<T> | null {
|
||||
if (!this.result || this.result.target !== target) return null
|
||||
const result = {
|
||||
context: this.result.context as T | undefined,
|
||||
media: [...this.result.media],
|
||||
}
|
||||
this.result = null
|
||||
return result
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -41,9 +41,13 @@ const messageMedia = useMessageMediaStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const requestedMessageMedia = computed<GalleryFilter | null>(() => {
|
||||
const value = route.query.messageAttachment
|
||||
const value = route.query.mediaAttachment ?? route.query.messageAttachment
|
||||
return value === 'photo' || value === 'video' ? value : null
|
||||
})
|
||||
const multipleSelection = computed(
|
||||
() => requestedMessageMedia.value !== null && (messageMedia.request?.maxSelection ?? 1) > 1,
|
||||
)
|
||||
const selectedMediaIds = ref<number[]>([])
|
||||
const media = ref<PhoneMedia[]>([])
|
||||
const filter = ref<GalleryFilter>(requestedMessageMedia.value ?? 'all')
|
||||
const loading = ref(true)
|
||||
@@ -203,6 +207,14 @@ function observeMore(): void {
|
||||
|
||||
function openMedia(entry: PhoneMedia): void {
|
||||
if (requestedMessageMedia.value) {
|
||||
if (multipleSelection.value) {
|
||||
const index = selectedMediaIds.value.indexOf(entry.id)
|
||||
if (index >= 0) selectedMediaIds.value.splice(index, 1)
|
||||
else if (selectedMediaIds.value.length < (messageMedia.request?.maxSelection ?? 1)) {
|
||||
selectedMediaIds.value.push(entry.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
const returnPath = messageMedia.complete(entry)
|
||||
if (returnPath) void router.replace(returnPath)
|
||||
return
|
||||
@@ -214,6 +226,15 @@ function openMedia(entry: PhoneMedia): void {
|
||||
imagePan.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
function completeMultipleSelection(): void {
|
||||
const selectedMedia = selectedMediaIds.value.flatMap((id) => {
|
||||
const entry = media.value.find((item) => item.id === id)
|
||||
return entry ? [entry] : []
|
||||
})
|
||||
const returnPath = messageMedia.completeMany(selectedMedia)
|
||||
if (returnPath) void router.replace(returnPath)
|
||||
}
|
||||
|
||||
function cancelMessageSelection(): void {
|
||||
void router.replace(messageMedia.cancel())
|
||||
}
|
||||
@@ -352,6 +373,15 @@ onBeforeUnmount(() => {
|
||||
@click="cancelMessageSelection"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="multipleSelection" #right>
|
||||
<k-link
|
||||
component="button"
|
||||
:disabled="!selectedMediaIds.length"
|
||||
@click="completeMultipleSelection"
|
||||
>
|
||||
{{ phone.t('Common.done') }}
|
||||
</k-link>
|
||||
</template>
|
||||
</k-navbar>
|
||||
|
||||
<div class="gallery-content">
|
||||
@@ -375,6 +405,7 @@ onBeforeUnmount(() => {
|
||||
v-for="entry in media"
|
||||
:key="entry.id"
|
||||
class="gallery-tile"
|
||||
:class="{ 'gallery-tile--selected': selectedMediaIds.includes(entry.id) }"
|
||||
type="button"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
@@ -401,6 +432,12 @@ onBeforeUnmount(() => {
|
||||
<span v-if="entry.mediaType === 'video'" class="gallery-video-badge">
|
||||
<Play :size="16" fill="currentColor" />
|
||||
</span>
|
||||
<span
|
||||
v-if="multipleSelection && selectedMediaIds.includes(entry.id)"
|
||||
class="gallery-selection-badge"
|
||||
>
|
||||
{{ selectedMediaIds.indexOf(entry.id) + 1 }}
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
v-if="hasMore"
|
||||
@@ -594,6 +631,29 @@ onBeforeUnmount(() => {
|
||||
border: 0;
|
||||
background: #d1d1d6;
|
||||
}
|
||||
.gallery-tile--selected::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 3px solid #0a84ff;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gallery-selection-badge {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
background: #0a84ff;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 6px #0006;
|
||||
}
|
||||
.gallery-grid--fill .gallery-tile {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -11,31 +11,51 @@ import {
|
||||
Images,
|
||||
MapPin,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Store,
|
||||
Trash2,
|
||||
UserRound,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
kButton,
|
||||
kGlass,
|
||||
kIcon,
|
||||
kNavbar,
|
||||
kPage,
|
||||
kSearchbar,
|
||||
kTabbar,
|
||||
kTabbarLink,
|
||||
kToolbarPane,
|
||||
} from 'konsta/vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
|
||||
import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { usePagesStore } from '@/stores/pages'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PagesCategory, PagesPost } from '@/types/pages'
|
||||
|
||||
type SelectedPhoto = { background: string; id: string }
|
||||
type ComposeDraft = {
|
||||
body: string
|
||||
category: Exclude<PagesCategory, 'citymarkt'>
|
||||
district: string
|
||||
images: string[]
|
||||
title: string
|
||||
}
|
||||
type MediaContext = { draft: ComposeDraft; photos: SelectedPhoto[] }
|
||||
|
||||
type Screen = 'main' | 'detail' | 'compose'
|
||||
type Tab = 'feed' | 'create' | 'profile'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
const media = useMediaStore()
|
||||
const messageMedia = useMessageMediaStore()
|
||||
const pages = usePagesStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const screen = ref<Screen>('main')
|
||||
const tab = ref<Tab>('feed')
|
||||
@@ -46,9 +66,8 @@ const search = ref('')
|
||||
const category = ref<string>('all')
|
||||
const feedback = ref('')
|
||||
const reactionPending = ref(false)
|
||||
const photoSource = ref<'camera' | 'gallery' | null>(null)
|
||||
const cameraFlash = ref(false)
|
||||
const draft = ref({
|
||||
const pickedPhotos = ref<SelectedPhoto[]>([])
|
||||
const draft = ref<ComposeDraft>({
|
||||
body: '',
|
||||
category: 'recommendation' as Exclude<PagesCategory, 'citymarkt'>,
|
||||
district: 'los_santos',
|
||||
@@ -76,10 +95,10 @@ const displayedPosts = computed(() => tab.value === 'profile'
|
||||
: pages.items)
|
||||
const isAuthenticated = computed(() => Boolean(account.email))
|
||||
const selectedPhotos = computed(() => draft.value.images
|
||||
.map((id) => media.photos.find((photo) => photo.id === id))
|
||||
.map((id) => pickedPhotos.value.find((photo) => photo.id === id))
|
||||
.filter((photo) => photo !== undefined))
|
||||
const selectedImages = computed(() => selectedPhotos.value.map((photo, index) => ({
|
||||
gradient: photo.gradient,
|
||||
gradient: photo.background,
|
||||
media_id: photo.id,
|
||||
sort_order: index + 1,
|
||||
})))
|
||||
@@ -110,6 +129,15 @@ async function loadFeed(): Promise<void> {
|
||||
await pages.load({ category: category.value, search: search.value })
|
||||
}
|
||||
|
||||
function updateSearch(event: Event): void {
|
||||
search.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
function clearSearch(): void {
|
||||
search.value = ''
|
||||
void loadFeed()
|
||||
}
|
||||
|
||||
async function selectTab(next: Tab): Promise<void> {
|
||||
if (next === 'create') {
|
||||
if (!isAuthenticated.value) {
|
||||
@@ -134,19 +162,29 @@ async function openPost(post: PagesPost): Promise<void> {
|
||||
|
||||
function togglePhoto(id: string): void {
|
||||
const index = draft.value.images.indexOf(id)
|
||||
if (index >= 0) draft.value.images.splice(index, 1)
|
||||
else if (draft.value.images.length < 6) draft.value.images.push(id)
|
||||
else showFeedback('Apps.localPages.photoLimit')
|
||||
if (index >= 0) {
|
||||
draft.value.images.splice(index, 1)
|
||||
pickedPhotos.value = pickedPhotos.value.filter((photo) => photo.id !== id)
|
||||
}
|
||||
}
|
||||
|
||||
function capturePhoto(): void {
|
||||
if (draft.value.images.length >= 6) {
|
||||
function openMediaApp(app: 'camera' | 'photos'): void {
|
||||
const remaining = 6 - draft.value.images.length
|
||||
if (remaining < 1) {
|
||||
showFeedback('Apps.localPages.photoLimit')
|
||||
return
|
||||
}
|
||||
cameraFlash.value = true
|
||||
draft.value.images.push(media.capture().id)
|
||||
window.setTimeout(() => (cameraFlash.value = false), 120)
|
||||
messageMedia.begin(
|
||||
'local-pages:compose',
|
||||
'photo',
|
||||
'/apps/local-pages?compose=1',
|
||||
app === 'photos' ? remaining : 1,
|
||||
{
|
||||
draft: { ...draft.value, images: [...draft.value.images] },
|
||||
photos: [...pickedPhotos.value],
|
||||
} satisfies MediaContext,
|
||||
)
|
||||
void router.push({ path: `/apps/${app}`, query: { mediaAttachment: 'photo' } })
|
||||
}
|
||||
|
||||
async function publish(): Promise<void> {
|
||||
@@ -166,7 +204,7 @@ async function publish(): Promise<void> {
|
||||
return
|
||||
}
|
||||
draft.value = { body: '', category: 'recommendation', district: 'los_santos', images: [], title: '' }
|
||||
photoSource.value = null
|
||||
pickedPhotos.value = []
|
||||
tab.value = 'feed'
|
||||
screen.value = 'main'
|
||||
showFeedback('Apps.localPages.published')
|
||||
@@ -225,40 +263,72 @@ function openCityMarktListing(): void {
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => void loadFeed())
|
||||
onMounted(() => {
|
||||
const selection = messageMedia.consumeMany<MediaContext>('local-pages:compose')
|
||||
if (selection) {
|
||||
if (selection.context) {
|
||||
draft.value = selection.context.draft
|
||||
pickedPhotos.value = selection.context.photos
|
||||
}
|
||||
for (const media of selection.media) {
|
||||
const id = String(media.id)
|
||||
if (draft.value.images.includes(id) || draft.value.images.length >= 6) continue
|
||||
draft.value.images.push(id)
|
||||
pickedPhotos.value.push({ background: `url(${JSON.stringify(media.url)})`, id })
|
||||
}
|
||||
}
|
||||
if (route.query.compose === '1') screen.value = 'compose'
|
||||
void loadFeed()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="pages" :class="{ 'pages--light': !phone.isDarkMode }">
|
||||
<k-page
|
||||
component="main"
|
||||
class="pages pb-safe-24"
|
||||
:class="{ 'pages--light': !phone.isDarkMode }"
|
||||
:colors="{ bgIos: 'bg-transparent' }"
|
||||
>
|
||||
<template v-if="screen === 'main'">
|
||||
<header class="pages__header">
|
||||
<div>
|
||||
<span class="pages__brand">
|
||||
<MapPin :size="14" />
|
||||
{{ phone.t(tab === 'feed' ? 'Apps.localPages.eyebrow' : 'Apps.localPages.name') }}
|
||||
</span>
|
||||
<h1>{{ phone.t(tab === 'feed' ? 'Apps.localPages.name' : 'Apps.localPages.profile') }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
<k-navbar
|
||||
class="pages-navbar"
|
||||
:subtitle="phone.t(tab === 'feed' ? 'Apps.localPages.eyebrow' : 'Apps.localPages.name')"
|
||||
:title="phone.t(tab === 'feed' ? 'Apps.localPages.name' : 'Apps.localPages.profile')"
|
||||
/>
|
||||
|
||||
<section class="pages__content">
|
||||
<template v-if="tab === 'feed'">
|
||||
<div class="pages__hero"><div><small>{{ phone.t('Apps.localPages.cityPulse') }}</small><strong>{{ phone.t('Apps.localPages.heroTitle') }}</strong><span>{{ phone.t('Apps.localPages.heroBody') }}</span></div><MapPin :size="40" /></div>
|
||||
<form class="pages__search" @submit.prevent="loadFeed"><Search :size="17" /><input v-model="search" :placeholder="phone.t('Apps.localPages.searchPlaceholder')" /><button>{{ phone.t('Apps.localPages.search') }}</button></form>
|
||||
<k-glass class="pages-hero-glass">
|
||||
<div class="pages__hero"><div><small>{{ phone.t('Apps.localPages.cityPulse') }}</small><strong>{{ phone.t('Apps.localPages.heroTitle') }}</strong><span>{{ phone.t('Apps.localPages.heroBody') }}</span></div><MapPin :size="40" /></div>
|
||||
</k-glass>
|
||||
<k-searchbar
|
||||
component="form"
|
||||
class="pages-searchbar"
|
||||
:value="search"
|
||||
:placeholder="phone.t('Apps.localPages.searchPlaceholder')"
|
||||
@input="updateSearch"
|
||||
@clear="clearSearch"
|
||||
@submit.prevent="loadFeed"
|
||||
/>
|
||||
<CityMarktSelect :model-value="category" :options="categoryOptions" @change="(value) => { category = value; loadFeed() }" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="!isAuthenticated" class="pages__empty"><UserRound :size="42" /><strong>{{ phone.t('Apps.localPages.signInTitle') }}</strong><span>{{ phone.t('Apps.localPages.signInBody') }}</span></div>
|
||||
<template v-else>
|
||||
<div class="pages__profile"><span>{{ account.email.charAt(0).toUpperCase() }}</span><div><small>{{ phone.t('Apps.localPages.localCreator') }}</small><strong>@{{ account.email.split('@')[0] }}</strong><b>{{ pages.ownItems.length }} {{ phone.t('Apps.localPages.posts') }}</b></div></div>
|
||||
<div class="pages__segmented"><button :class="{ active: profileMode === 'own' }" @click="profileMode = 'own'">{{ phone.t('Apps.localPages.myPosts') }}</button><button :class="{ active: profileMode === 'saved' }" @click="profileMode = 'saved'">{{ phone.t('Apps.localPages.saved') }}</button></div>
|
||||
<k-glass class="pages-profile-glass">
|
||||
<div class="pages__profile"><span>{{ account.email.charAt(0).toUpperCase() }}</span><div><small>{{ phone.t('Apps.localPages.localCreator') }}</small><strong>@{{ account.email.split('@')[0] }}</strong><b>{{ pages.ownItems.length }} {{ phone.t('Apps.localPages.posts') }}</b></div></div>
|
||||
</k-glass>
|
||||
<k-glass class="pages-segmented-glass">
|
||||
<div class="pages__segmented"><button :class="{ active: profileMode === 'own' }" @click="profileMode = 'own'">{{ phone.t('Apps.localPages.myPosts') }}</button><button :class="{ active: profileMode === 'saved' }" @click="profileMode = 'saved'">{{ phone.t('Apps.localPages.saved') }}</button></div>
|
||||
</k-glass>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div v-if="pages.isLoading" class="pages__empty">{{ phone.t('Common.loading') }}</div>
|
||||
<div v-else-if="isAuthenticated || tab === 'feed'" class="pages__feed">
|
||||
<article v-for="post in displayedPosts" :key="post.id" class="pages__post">
|
||||
<k-glass v-for="post in displayedPosts" :key="post.id" class="pages-post-glass">
|
||||
<article class="pages__post">
|
||||
<button class="pages__post-open" type="button" @click="openPost(post)">
|
||||
<div class="pages__post-head"><span>{{ post.author_name.charAt(0).toUpperCase() }}</span><div><strong>@{{ post.author_name }}</strong><small><MapPin :size="10" /> {{ post.district ? phone.t(`Apps.citymarkt.districts.${post.district}`) : phone.t('Apps.localPages.allLosSantos') }} · {{ relativeDate(post.created_at) }}</small></div><i>{{ label('categories', post.category) }}</i></div>
|
||||
<div v-if="post.image" class="pages__cover" :style="{ background: post.image }"><b v-if="post.images.length > 1">1 / {{ post.images.length }}</b></div>
|
||||
@@ -269,20 +339,53 @@ onMounted(() => void loadFeed())
|
||||
<span v-if="post.source_type === 'citymarkt'"><Store :size="14" /> CityMarkt</span>
|
||||
<button type="button" :class="{ active: post.is_saved }" :disabled="reactionPending" :aria-label="phone.t('Apps.localPages.save')" @click="reactToPost(post, 'save')"><Bookmark :size="15" :fill="post.is_saved ? 'currentColor' : 'none'" /> {{ phone.t('Apps.localPages.save') }}</button>
|
||||
</div>
|
||||
</article>
|
||||
</article>
|
||||
</k-glass>
|
||||
<div v-if="!displayedPosts.length" class="pages__empty"><Compass :size="38" /><strong>{{ phone.t('Apps.localPages.noPosts') }}</strong><span>{{ phone.t('Apps.localPages.noPostsBody') }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="pages__tabbar">
|
||||
<button :class="{ active: tab === 'feed' }" @click="selectTab('feed')"><span><Compass :size="20" /></span>{{ phone.t('Apps.localPages.discover') }}</button>
|
||||
<button class="create" @click="selectTab('create')"><span><Plus :size="23" /></span>{{ phone.t('Apps.localPages.create') }}</button>
|
||||
<button :class="{ active: tab === 'profile' }" @click="selectTab('profile')"><span><UserRound :size="20" /></span>{{ phone.t('Apps.localPages.profile') }}</button>
|
||||
</nav>
|
||||
<k-tabbar
|
||||
component="nav"
|
||||
icons
|
||||
labels
|
||||
class="bottom-0 left-0 fixed"
|
||||
inner-class="!w-full !max-w-none !gap-0 !px-1"
|
||||
:aria-label="phone.t('Apps.localPages.name')"
|
||||
>
|
||||
<k-toolbar-pane class="pages__tab-pane">
|
||||
<k-tabbar-link
|
||||
component="button"
|
||||
:active="tab === 'feed'"
|
||||
:link-props="{ class: 'pages-tab-button', type: 'button' }"
|
||||
@click="selectTab('feed')"
|
||||
>
|
||||
<template #label><span class="pages__tab-label">{{ phone.t('Apps.localPages.discover') }}</span></template>
|
||||
<template #icon><k-icon><Compass :size="20" /></k-icon></template>
|
||||
</k-tabbar-link>
|
||||
<k-tabbar-link
|
||||
component="button"
|
||||
:link-props="{ class: 'pages-tab-button', type: 'button' }"
|
||||
@click="selectTab('create')"
|
||||
>
|
||||
<template #label><span class="pages__tab-label">{{ phone.t('Apps.localPages.create') }}</span></template>
|
||||
<template #icon><span class="pages__tab-icon pages__tab-icon--create"><k-icon><Plus :size="21" /></k-icon></span></template>
|
||||
</k-tabbar-link>
|
||||
<k-tabbar-link
|
||||
component="button"
|
||||
:active="tab === 'profile'"
|
||||
:link-props="{ class: 'pages-tab-button', type: 'button' }"
|
||||
@click="selectTab('profile')"
|
||||
>
|
||||
<template #label><span class="pages__tab-label">{{ phone.t('Apps.localPages.profile') }}</span></template>
|
||||
<template #icon><k-icon><UserRound :size="20" /></k-icon></template>
|
||||
</k-tabbar-link>
|
||||
</k-toolbar-pane>
|
||||
</k-tabbar>
|
||||
</template>
|
||||
|
||||
<section v-else-if="screen === 'detail' && selected" class="pages__detail">
|
||||
<header><button @click="screen = 'main'"><ArrowLeft :size="20" /></button><strong>{{ phone.t('Apps.localPages.post') }}</strong><button v-if="selected.is_owner" class="danger" @click="removePost"><Trash2 :size="18" /></button><button v-else @click="react('save')"><Bookmark :size="18" :fill="selected.is_saved ? 'currentColor' : 'none'" /></button></header>
|
||||
<header><k-button component="button" clear rounded @click="screen = 'main'"><ArrowLeft :size="20" /></k-button><strong>{{ phone.t('Apps.localPages.post') }}</strong><k-button v-if="selected.is_owner" component="button" clear rounded class="danger" @click="removePost"><Trash2 :size="18" /></k-button><k-button v-else component="button" clear rounded @click="react('save')"><Bookmark :size="18" :fill="selected.is_saved ? 'currentColor' : 'none'" /></k-button></header>
|
||||
<div class="pages__detail-scroll">
|
||||
<div v-if="selected.images.length" class="pages__gallery" :style="{ background: selected.images[galleryIndex]?.gradient }"><button v-if="selected.images.length > 1" @click="moveGallery(-1)"><ChevronLeft /></button><button v-if="selected.images.length > 1" @click="moveGallery(1)"><ChevronRight /></button><span>{{ galleryIndex + 1 }} / {{ selected.images.length }}</span></div>
|
||||
<article><div class="pages__author"><span>{{ selected.author_name.charAt(0).toUpperCase() }}</span><div><strong>@{{ selected.author_name }}</strong><small>{{ relativeDate(selected.created_at) }}</small></div><i>{{ label('categories', selected.category) }}</i></div><h1>{{ selected.title }}</h1><p>{{ selected.body }}</p><div class="pages__location"><MapPin :size="17" /><div><small>{{ phone.t('Apps.localPages.location') }}</small><strong>{{ selected.district ? phone.t(`Apps.citymarkt.districts.${selected.district}`) : phone.t('Apps.localPages.allLosSantos') }}</strong></div></div><button v-if="selected.source_type === 'citymarkt'" class="pages__market-link" @click="openCityMarktListing"><Store :size="18" /><span><small>{{ phone.t('Apps.localPages.sharedFrom') }}</small><strong>{{ phone.t('Apps.localPages.openCityMarkt') }}</strong></span><b v-if="selected.citymarkt_price">${{ Number(selected.citymarkt_price).toLocaleString() }}</b></button></article>
|
||||
@@ -291,26 +394,44 @@ onMounted(() => void loadFeed())
|
||||
</section>
|
||||
|
||||
<section v-else class="pages__compose">
|
||||
<header><button @click="screen = 'main'"><X :size="20" /></button><div><small>{{ phone.t('Apps.localPages.newPost') }}</small><strong>{{ phone.t('Apps.localPages.shareWithCity') }}</strong></div><button :disabled="!canPublish" @click="publish"><Send :size="16" />{{ phone.t('Apps.localPages.publish') }}</button></header>
|
||||
<k-navbar
|
||||
class="pages-create-navbar"
|
||||
center-title
|
||||
left-class="pages-create-action pages-create-action--close !w-11 !min-w-11 !max-w-11 !h-11 !p-0 !rounded-full"
|
||||
right-class="pages-create-action pages-create-action--publish !min-w-[58px] !h-11 !p-0 !rounded-full"
|
||||
:title="phone.t('Apps.localPages.shareWithCity')"
|
||||
:subtitle="phone.t('Apps.localPages.newPost')"
|
||||
>
|
||||
<template #left>
|
||||
<button class="pages-create-close" type="button" :aria-label="phone.t('Common.close')" @click="screen = 'main'">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</template>
|
||||
<template #right>
|
||||
<button class="pages-create-publish" type="button" :disabled="!canPublish" @click="publish">
|
||||
{{ phone.t('Apps.localPages.publish') }}
|
||||
</button>
|
||||
</template>
|
||||
</k-navbar>
|
||||
<div class="pages__compose-scroll">
|
||||
<label>{{ phone.t('Apps.localPages.title') }} <span :class="{ valid: draft.title.trim().length >= 5 }">{{ draft.title.trim().length }}/80 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '5' }) }}</span><input v-model="draft.title" maxlength="80" :placeholder="phone.t('Apps.localPages.titlePlaceholder')" /></label>
|
||||
<label>{{ phone.t('Apps.localPages.body') }} <span :class="{ valid: draft.body.trim().length >= 10 }">{{ draft.body.trim().length }}/1500 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '10' }) }}</span><textarea v-model="draft.body" maxlength="1500" :placeholder="phone.t('Apps.localPages.bodyPlaceholder')" /></label>
|
||||
<label>{{ phone.t('Apps.localPages.title') }} <span :class="{ valid: draft.title.trim().length >= 5 }">{{ draft.title.trim().length }}/80 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '5' }) }}</span><k-glass class="pages__field-glass"><input v-model="draft.title" maxlength="80" :placeholder="phone.t('Apps.localPages.titlePlaceholder')" /></k-glass></label>
|
||||
<label>{{ phone.t('Apps.localPages.body') }} <span :class="{ valid: draft.body.trim().length >= 10 }">{{ draft.body.trim().length }}/1500 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '10' }) }}</span><k-glass class="pages__field-glass pages__field-glass--textarea"><textarea v-model="draft.body" maxlength="1500" :placeholder="phone.t('Apps.localPages.bodyPlaceholder')" /></k-glass></label>
|
||||
<div class="pages__form-row"><label>{{ phone.t('Apps.localPages.category') }}<CityMarktSelect :model-value="draft.category" :options="composeCategoryOptions" @change="(value) => draft.category = value as typeof draft.category" /></label><label>{{ phone.t('Apps.localPages.location') }}<CityMarktSelect :model-value="draft.district" :options="districtOptions" @change="(value) => draft.district = value" /></label></div>
|
||||
<section class="pages__photos">
|
||||
<ImagePlus :size="30" />
|
||||
<h2>{{ phone.t('Apps.citymarkt.addPhotos') }}</h2>
|
||||
<p>{{ phone.t('Apps.citymarkt.addPhotosBody') }}</p>
|
||||
<div class="pages__photo-actions">
|
||||
<button type="button" @click="photoSource = 'gallery'">
|
||||
<k-glass><button type="button" @click="openMediaApp('photos')">
|
||||
<span><Images :size="20" /></span>
|
||||
<strong>{{ phone.t('Apps.citymarkt.chooseGallery') }}</strong>
|
||||
<small>{{ phone.t('Apps.citymarkt.chooseGalleryBody') }}</small>
|
||||
</button>
|
||||
<button type="button" @click="photoSource = 'camera'">
|
||||
</button></k-glass>
|
||||
<k-glass><button type="button" @click="openMediaApp('camera')">
|
||||
<span><Camera :size="20" /></span>
|
||||
<strong>{{ phone.t('Apps.citymarkt.takePhotos') }}</strong>
|
||||
<small>{{ phone.t('Apps.citymarkt.takePhotosBody') }}</small>
|
||||
</button>
|
||||
</button></k-glass>
|
||||
</div>
|
||||
<div class="pages__selected-heading">
|
||||
<strong>{{ phone.t('Apps.citymarkt.selectedPhotos') }}</strong>
|
||||
@@ -330,27 +451,9 @@ onMounted(() => void loadFeed())
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="photoSource" class="pages__photo-source">
|
||||
<header>
|
||||
<div><small>{{ phone.t('Apps.citymarkt.addPhotos') }}</small><strong>{{ phone.t(photoSource === 'gallery' ? 'Apps.citymarkt.gallery' : 'Apps.citymarkt.camera') }}</strong></div>
|
||||
<span>{{ draft.images.length }} / 6</span>
|
||||
<button type="button" :aria-label="phone.t('Common.close')" @click="photoSource = null"><X :size="18" /></button>
|
||||
</header>
|
||||
<div v-if="photoSource === 'gallery'" class="pages__photo-picker">
|
||||
<button v-for="photo in media.photos" :key="photo.id" type="button" :class="{ active: draft.images.includes(photo.id) }" :style="{ background: photo.gradient }" @click="togglePhoto(photo.id)"><i v-if="draft.images.includes(photo.id)">{{ draft.images.indexOf(photo.id) + 1 }}</i></button>
|
||||
</div>
|
||||
<div v-else class="pages__capture">
|
||||
<div class="pages__viewfinder" :style="{ background: media.photos[0]?.gradient }">
|
||||
<i v-for="corner in ['tl', 'tr', 'bl', 'br']" :key="corner" :class="`corner-${corner}`" />
|
||||
<span class="pages__camera-flash" :class="{ active: cameraFlash }" />
|
||||
</div>
|
||||
<p>{{ phone.t('Apps.citymarkt.cameraHint') }}</p>
|
||||
<button class="pages__shutter" type="button" :aria-label="phone.t('Apps.citymarkt.takePhoto')" @click="capturePhoto"><Camera :size="23" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Transition name="toast"><div v-if="feedback" class="pages__toast">{{ feedback }}</div></Transition>
|
||||
</main>
|
||||
</k-page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -395,4 +498,220 @@ onMounted(() => void loadFeed())
|
||||
.pages__selected-heading span,.pages__photo-source>header small,.pages__photo-source>header>span{font-size:11px}
|
||||
.pages__selected-strip button i,.pages__photo-picker i{font-size:10px}
|
||||
.pages__toast{font-size:12px}
|
||||
.pages {
|
||||
--color-primary: var(--yellow);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background: #12171b !important;
|
||||
}
|
||||
.pages--light {
|
||||
background: #fbfbf6 !important;
|
||||
}
|
||||
.pages-navbar {
|
||||
--k-safe-area-top: 46px;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
.pages__content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
height: auto;
|
||||
padding: 108px 13px 112px;
|
||||
}
|
||||
.pages-hero-glass,
|
||||
.pages-profile-glass,
|
||||
.pages-segmented-glass,
|
||||
.pages-post-glass {
|
||||
width: 100%;
|
||||
border-radius: 17px;
|
||||
}
|
||||
.pages-hero-glass {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.pages__hero {
|
||||
height: 105px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.pages-searchbar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pages-profile-glass {
|
||||
margin: 3px 0 10px;
|
||||
}
|
||||
.pages__profile {
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.pages-segmented-glass {
|
||||
padding: 4px;
|
||||
border-radius: 13px;
|
||||
}
|
||||
.pages__segmented {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.pages-post-glass {
|
||||
overflow: hidden;
|
||||
}
|
||||
.pages__post {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.pages__tab-pane {
|
||||
width: 100% !important;
|
||||
max-width: none;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
justify-content: space-around;
|
||||
gap: 2px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.pages__tab-label {
|
||||
display: block;
|
||||
max-width: 52px;
|
||||
overflow: hidden;
|
||||
font-size: 9.5px;
|
||||
line-height: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
:global(.pages__tab-pane) {
|
||||
width: 100% !important;
|
||||
max-width: none;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
justify-content: space-around;
|
||||
}
|
||||
:global(.pages-tab-button) {
|
||||
width: 20% !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 58px !important;
|
||||
flex: 0 0 20% !important;
|
||||
padding-right: 3px !important;
|
||||
padding-left: 3px !important;
|
||||
}
|
||||
.pages-create-navbar {
|
||||
--k-safe-area-top: 46px;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
.pages-create-action {
|
||||
height: 44px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.pages-create-action--close {
|
||||
width: 44px;
|
||||
}
|
||||
.pages-create-action--publish {
|
||||
min-width: 58px;
|
||||
}
|
||||
.pages-create-close,
|
||||
.pages-create-publish {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.pages-create-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.pages-create-publish {
|
||||
min-width: 58px;
|
||||
padding: 0 13px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.pages-create-publish:disabled {
|
||||
opacity: 0.38;
|
||||
}
|
||||
.pages__compose-scroll {
|
||||
position: absolute;
|
||||
top: 104px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: auto;
|
||||
padding-bottom: 35px;
|
||||
}
|
||||
.pages__field-glass {
|
||||
min-height: 44px;
|
||||
margin-top: 5px;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pages__field-glass--textarea {
|
||||
min-height: 116px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.pages__field-glass > input,
|
||||
.pages__field-glass > textarea {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin: 0 !important;
|
||||
padding: 11px 14px !important;
|
||||
border: 0 !important;
|
||||
outline: 0;
|
||||
background: transparent !important;
|
||||
color: inherit;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
.pages__field-glass > textarea {
|
||||
min-height: 116px;
|
||||
resize: none;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.pages__photo-actions > * {
|
||||
min-width: 0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.pages__photo-actions > * > button {
|
||||
width: 100%;
|
||||
min-height: 118px;
|
||||
padding: 11px 9px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
}
|
||||
.pages__photo-actions > * > button > span {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--yellow);
|
||||
}
|
||||
.pages__tab-icon {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.pages__tab-icon--create {
|
||||
width: 38px;
|
||||
height: 30px;
|
||||
margin-top: -4px;
|
||||
border-radius: 10px;
|
||||
background: var(--yellow);
|
||||
color: #17191a;
|
||||
box-shadow: 0 4px 12px #00000030;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -123,8 +123,12 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="memory-app" :aria-label="phone.t('Apps.memory.name')">
|
||||
<header class="memory-header">
|
||||
<main
|
||||
class="memory-app"
|
||||
:class="{ 'memory-app--playing': game }"
|
||||
:aria-label="phone.t('Apps.memory.name')"
|
||||
>
|
||||
<header v-if="!game" class="memory-header">
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.memory.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.memory.name') }}</h1>
|
||||
@@ -278,6 +282,10 @@ onBeforeUnmount(() => {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.memory-app--playing {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.memory-header {
|
||||
height: 52px;
|
||||
display: flex;
|
||||
@@ -405,50 +413,67 @@ onBeforeUnmount(() => {
|
||||
.memory-difficulty__best { align-self: start; color: #74698f; font-size: 11.5px; line-height: 1.2; }
|
||||
.memory-difficulties svg { grid-column: 3; grid-row: 1 / 3; color: #7658c7; }
|
||||
|
||||
.memory-game { padding-top: 5px; }
|
||||
|
||||
.memory-stats {
|
||||
height: 50px;
|
||||
display: grid;
|
||||
grid-template-columns: 30px auto auto 1fr;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
.memory-game {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.memory-stats div { display: grid; }
|
||||
.memory-stats span { color: #74698f; font-size: 10.5px; font-weight: 800; text-transform: uppercase; }
|
||||
.memory-stats strong { font-size: 19px; line-height: 1.05; }
|
||||
.memory-stats button { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 14px; font-weight: 800; }
|
||||
.memory-stats {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
top: 66px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 1fr auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid rgb(105 79 160 / 10%);
|
||||
border-radius: 21px;
|
||||
background: rgb(246 241 255 / 64%);
|
||||
box-shadow: 0 8px 24px rgb(75 52 121 / 13%);
|
||||
backdrop-filter: blur(14px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.memory-stats div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
|
||||
.memory-stats span { color: #74698f; font-size: 10.5px; font-weight: 800; line-height: 10px; text-transform: uppercase; }
|
||||
.memory-stats strong { display: block; font-size: 19px; line-height: 20px; }
|
||||
.memory-stats button { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 13px; font-weight: 800; }
|
||||
.memory-stats .memory-menu-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
justify-self: start;
|
||||
padding: 0;
|
||||
border: 1px solid rgb(105 79 160 / 10%);
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 48%);
|
||||
box-shadow: 0 3px 8px rgb(75 52 121 / 8%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-board {
|
||||
position: relative;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 9px;
|
||||
border: 1px solid rgb(101 77 153 / 9%);
|
||||
border-radius: 20px;
|
||||
background: rgb(255 255 255 / 31%);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 50%);
|
||||
align-content: center;
|
||||
padding: 108px 17px 28px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background:
|
||||
radial-gradient(circle at 12% 88%, rgb(135 105 207 / 12%), transparent 31%),
|
||||
radial-gradient(circle at 88% 16%, rgb(255 255 255 / 54%), transparent 28%);
|
||||
perspective: 900px;
|
||||
}
|
||||
|
||||
.memory-board--small { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.memory-board--medium,
|
||||
.memory-board--large { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.memory-board--large { gap: 6px; padding: 7px; }
|
||||
.memory-board--large { gap: 6px; padding-right: 15px; padding-left: 15px; }
|
||||
|
||||
.memory-game-card {
|
||||
aspect-ratio: 0.82;
|
||||
@@ -545,7 +570,7 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border-radius: 20px;
|
||||
border-radius: 0;
|
||||
background: rgb(244 239 255 / 90%);
|
||||
backdrop-filter: blur(6px);
|
||||
text-align: center;
|
||||
|
||||
@@ -198,8 +198,12 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="minesweeper-app" :aria-label="phone.t('Apps.minesweeper.name')">
|
||||
<header class="minesweeper-header">
|
||||
<main
|
||||
class="minesweeper-app"
|
||||
:class="{ 'minesweeper-app--playing': !minesweeper.menuOpen && game }"
|
||||
:aria-label="phone.t('Apps.minesweeper.name')"
|
||||
>
|
||||
<header v-if="minesweeper.menuOpen" class="minesweeper-header">
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.minesweeper.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.minesweeper.name') }}</h1>
|
||||
@@ -420,6 +424,10 @@ onBeforeUnmount(() => {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.minesweeper-app--playing {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.minesweeper-header {
|
||||
height: 55px;
|
||||
display: flex;
|
||||
@@ -524,22 +532,59 @@ onBeforeUnmount(() => {
|
||||
.minesweeper-difficulties small { align-self: start; color: #658a89; font-size: 8px; }
|
||||
.minesweeper-long-press { margin: 0; color: #628887; font-size: 9px; }
|
||||
|
||||
.minesweeper-game { position: relative; padding-top: 5px; }
|
||||
.minesweeper-toolbar { height: 46px; display: grid; grid-template-columns: 36px 1fr 1fr 36px; align-items: center; gap: 7px; }
|
||||
.minesweeper-toolbar div { display: grid; justify-items: center; line-height: 1.05; }
|
||||
.minesweeper-toolbar span { color: #5a8484; font-size: 8px; font-weight: 800; text-transform: uppercase; }
|
||||
.minesweeper-toolbar strong { font-size: 16px; }
|
||||
.minesweeper-game {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 12% 88%, rgb(26 129 130 / 14%), transparent 32%),
|
||||
radial-gradient(circle at 88% 16%, rgb(238 255 249 / 45%), transparent 29%);
|
||||
}
|
||||
|
||||
.minesweeper-toolbar {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 66px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 1fr 32px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid rgb(23 83 87 / 10%);
|
||||
border-radius: 21px;
|
||||
background: rgb(226 249 242 / 65%);
|
||||
box-shadow: 0 8px 24px rgb(23 73 75 / 13%);
|
||||
backdrop-filter: blur(14px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.minesweeper-toolbar div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
|
||||
.minesweeper-toolbar span { color: #5a8484; font-size: 10px; font-weight: 800; line-height: 10px; text-transform: uppercase; }
|
||||
.minesweeper-toolbar strong { display: block; font-size: 18px; line-height: 20px; }
|
||||
|
||||
.minesweeper-toolbar .minesweeper-toolbar__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.minesweeper-board {
|
||||
position: relative;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 14px;
|
||||
left: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--minesweeper-columns), minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
padding: 7px;
|
||||
border: 1px solid rgb(16 78 82 / 10%);
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
background: #187a7e;
|
||||
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 15px 27px rgb(20 79 81 / 18%);
|
||||
transform: translateY(-45%);
|
||||
}
|
||||
|
||||
.minesweeper-cell {
|
||||
@@ -582,8 +627,7 @@ onBeforeUnmount(() => {
|
||||
.minesweeper-game--exploding::after {
|
||||
position: absolute;
|
||||
z-index: 9;
|
||||
inset: 46px 0 0;
|
||||
border-radius: 18px;
|
||||
inset: 0;
|
||||
background: #ffb341;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
@@ -767,13 +811,13 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
@keyframes minesweeper-board-shake {
|
||||
0%, 100% { transform: translate(0); }
|
||||
12% { transform: translate(-7px, 3px) rotate(-1deg); }
|
||||
24% { transform: translate(6px, -4px) rotate(1deg); }
|
||||
38% { transform: translate(-5px, -2px); }
|
||||
52% { transform: translate(4px, 3px); }
|
||||
68% { transform: translate(-2px, -2px); }
|
||||
82% { transform: translate(2px, 1px); }
|
||||
0%, 100% { transform: translate(0, -45%); }
|
||||
12% { transform: translate(-7px, calc(-45% + 3px)) rotate(-1deg); }
|
||||
24% { transform: translate(6px, calc(-45% - 4px)) rotate(1deg); }
|
||||
38% { transform: translate(-5px, calc(-45% - 2px)); }
|
||||
52% { transform: translate(4px, calc(-45% + 3px)); }
|
||||
68% { transform: translate(-2px, calc(-45% - 2px)); }
|
||||
82% { transform: translate(2px, calc(-45% + 1px)); }
|
||||
}
|
||||
|
||||
@keyframes minesweeper-bomb-drop {
|
||||
@@ -866,7 +910,22 @@ onBeforeUnmount(() => {
|
||||
.minesweeper-secondary { position: relative; z-index: 1; min-width: 155px; min-height: 40px; border-radius: 13px; font-size: 11px; font-weight: 850; pointer-events: auto; }
|
||||
.minesweeper-primary { border: 0; color: #104d51; background: #8ce3d2; }
|
||||
.minesweeper-secondary { border: 1px solid rgb(255 255 255 / 13%); color: #e6faf5; background: rgb(255 255 255 / 7%); }
|
||||
.minesweeper-game__hint { margin: 9px 0 0; color: #668c8c; font-size: 9px; text-align: center; }
|
||||
.minesweeper-game__hint {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
right: 45px;
|
||||
bottom: 27px;
|
||||
left: 45px;
|
||||
margin: 0;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
color: #668c8c;
|
||||
background: rgb(226 249 242 / 52%);
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 9px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
button:active { transform: scale(0.96); }
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
ChevronsDown,
|
||||
Pause,
|
||||
Play,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Trophy,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
@@ -201,8 +196,12 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="neon-drop-app" :aria-label="phone.t('Apps.neonDrop.name')">
|
||||
<header class="neon-header">
|
||||
<main
|
||||
class="neon-drop-app"
|
||||
:class="{ 'neon-drop-app--playing': !neon.menuOpen && game }"
|
||||
:aria-label="phone.t('Apps.neonDrop.name')"
|
||||
>
|
||||
<header v-if="neon.menuOpen" class="neon-header">
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.neonDrop.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.neonDrop.name') }}</h1>
|
||||
@@ -335,51 +334,11 @@ onBeforeUnmount(() => {
|
||||
<span>{{ phone.t('Apps.neonDrop.level') }}</span
|
||||
><strong>{{ game.level }}</strong>
|
||||
<div class="neon-level">
|
||||
<i :style="{ height: `${levelProgress * 100}%` }"></i>
|
||||
<i :style="{ width: `${levelProgress * 100}%` }"></i>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="neon-controls"
|
||||
:aria-label="phone.t('Apps.neonDrop.controls')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.neonDrop.left')"
|
||||
@click="move(-1)"
|
||||
>
|
||||
<ArrowLeft :size="18" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.neonDrop.rotate')"
|
||||
@click="rotate"
|
||||
>
|
||||
<RotateCw :size="18" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.neonDrop.right')"
|
||||
@click="move(1)"
|
||||
>
|
||||
<ArrowRight :size="18" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.neonDrop.softDrop')"
|
||||
@click="softDrop"
|
||||
>
|
||||
<ArrowDown :size="18" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.neonDrop.hardDrop')"
|
||||
@click="hardDrop"
|
||||
>
|
||||
<ChevronsDown :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="neon-hint">{{ phone.t('Apps.neonDrop.gameHint') }}</p>
|
||||
|
||||
<div v-if="game.status === 'paused'" class="neon-overlay">
|
||||
@@ -421,6 +380,9 @@ onBeforeUnmount(() => {
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.neon-drop-app--playing {
|
||||
padding: 0;
|
||||
}
|
||||
.neon-header {
|
||||
height: 54px;
|
||||
display: flex;
|
||||
@@ -613,42 +575,70 @@ onBeforeUnmount(() => {
|
||||
background: #ffffff0a;
|
||||
}
|
||||
.neon-game {
|
||||
position: relative;
|
||||
height: calc(100% - 54px);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.neon-toolbar {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 66px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 35px 1fr 1fr 35px;
|
||||
grid-template-columns: 32px 1fr 1fr 32px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid #7cf6e72b;
|
||||
border-radius: 21px;
|
||||
background: #101a38a8;
|
||||
box-shadow: 0 8px 24px #0008;
|
||||
backdrop-filter: blur(14px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.neon-toolbar span {
|
||||
font-size: 11px;
|
||||
line-height: 10px;
|
||||
}
|
||||
.neon-toolbar div {
|
||||
text-align: center;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
grid-template-rows: 10px 20px;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
}
|
||||
.neon-toolbar strong {
|
||||
font-size: 21px;
|
||||
display: block;
|
||||
font-size: 19px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.neon-toolbar button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: none;
|
||||
}
|
||||
.neon-play-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.neon-board {
|
||||
width: 218px;
|
||||
height: 414px;
|
||||
position: absolute;
|
||||
top: 156px;
|
||||
right: 0;
|
||||
bottom: 76px;
|
||||
left: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
grid-template-rows: repeat(18, 1fr);
|
||||
gap: 1.5px;
|
||||
padding: 5px;
|
||||
border: 1px solid #65f4e43b;
|
||||
border-radius: 13px;
|
||||
background: #06091af2;
|
||||
box-shadow:
|
||||
inset 0 0 25px #000b,
|
||||
0 12px 26px #0008;
|
||||
grid-template-rows: repeat(17, 1fr);
|
||||
gap: 1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.neon-cell {
|
||||
@@ -700,31 +690,35 @@ onBeforeUnmount(() => {
|
||||
animation: neon-clear 0.24s ease-out;
|
||||
}
|
||||
.neon-side {
|
||||
width: 70px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
top: 110px;
|
||||
right: 14px;
|
||||
left: 14px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 40px auto auto 1fr;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 10px 4px 11px;
|
||||
border: 1px solid #65f4e448;
|
||||
border-radius: 13px;
|
||||
background: linear-gradient(180deg, #1420478f, #090d24a3);
|
||||
box-shadow: inset 0 0 16px #53e8d90a;
|
||||
gap: 8px;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.neon-side > strong {
|
||||
font-size: 28px;
|
||||
font-size: 21px;
|
||||
}
|
||||
.neon-preview {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
gap: 2px;
|
||||
padding: 5px;
|
||||
border: 1px solid #ffffff35;
|
||||
border-radius: 10px;
|
||||
background: #ffffff08;
|
||||
gap: 1px;
|
||||
padding: 3px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.neon-preview i[class*='neon-piece--'] {
|
||||
border: 1px solid #ffffff68;
|
||||
@@ -734,63 +728,49 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.neon-level {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 221px;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #ffffff0c;
|
||||
}
|
||||
.neon-level i {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(#73a7ff, #71f5e5);
|
||||
box-shadow: 0 0 9px #69f4e5;
|
||||
transition: height 0.25s;
|
||||
}
|
||||
.neon-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 5px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.neon-controls button {
|
||||
height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid #ffffff16;
|
||||
border-radius: 11px;
|
||||
color: #dffefd;
|
||||
background: #ffffff0b;
|
||||
}
|
||||
.neon-controls button:last-child {
|
||||
color: #071225;
|
||||
background: linear-gradient(135deg, #ffe265, #ff9167);
|
||||
}
|
||||
.neon-controls svg {
|
||||
transform: scale(1.2);
|
||||
transition: width 0.25s;
|
||||
}
|
||||
.neon-hint {
|
||||
margin: 7px 0 0;
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
right: 45px;
|
||||
bottom: 27px;
|
||||
left: 45px;
|
||||
margin: 0;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
color: #d6deed;
|
||||
font-size: 16px;
|
||||
background: #101a3891;
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.neon-overlay {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
inset: 42px 0 19px;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 25px;
|
||||
border-radius: 17px;
|
||||
background: #080c21e8;
|
||||
backdrop-filter: blur(6px);
|
||||
text-align: center;
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ChevronLeft,
|
||||
RotateCcw,
|
||||
Volume2,
|
||||
@@ -30,16 +26,6 @@ const canResume = computed(
|
||||
const currentHighest = computed(() =>
|
||||
Math.max(0, ...(numberMerge.game?.tiles.map((tile) => tile.value) ?? [])),
|
||||
)
|
||||
const directionButtons: Array<{
|
||||
direction: NumberMergeDirection
|
||||
icon: typeof ArrowUp
|
||||
}> = [
|
||||
{ direction: 'left', icon: ArrowLeft },
|
||||
{ direction: 'up', icon: ArrowUp },
|
||||
{ direction: 'down', icon: ArrowDown },
|
||||
{ direction: 'right', icon: ArrowRight },
|
||||
]
|
||||
|
||||
function formatScore(value: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(value)
|
||||
}
|
||||
@@ -139,8 +125,12 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="number-merge-app" :aria-label="phone.t('Apps.numberMerge.name')">
|
||||
<header class="number-merge-header">
|
||||
<main
|
||||
class="number-merge-app"
|
||||
:class="{ 'number-merge-app--playing': !numberMerge.menuOpen && game }"
|
||||
:aria-label="phone.t('Apps.numberMerge.name')"
|
||||
>
|
||||
<header v-if="numberMerge.menuOpen" class="number-merge-header">
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.numberMerge.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.numberMerge.name') }}</h1>
|
||||
@@ -317,17 +307,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
</div>
|
||||
|
||||
<p class="number-merge-hint">{{ phone.t('Apps.numberMerge.swipeHint') }}</p>
|
||||
<div class="number-merge-controls" :aria-label="phone.t('Apps.numberMerge.controls')">
|
||||
<button
|
||||
v-for="control in directionButtons"
|
||||
:key="control.direction"
|
||||
type="button"
|
||||
:aria-label="phone.t(`Apps.numberMerge.directions.${control.direction}`)"
|
||||
@click="move(control.direction)"
|
||||
>
|
||||
<component :is="control.icon" :size="19" :stroke-width="2.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="confirmNewGame" class="number-merge-confirm" role="dialog" aria-modal="true">
|
||||
@@ -360,6 +339,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.number-merge-app--playing {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.number-merge-header {
|
||||
height: 55px;
|
||||
display: flex;
|
||||
@@ -508,40 +491,73 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
}
|
||||
.number-merge-how-to small { display: block; color: #8b6559; font-size: 11px; line-height: 1.35; }
|
||||
|
||||
.number-merge-game { padding-top: 5px; }
|
||||
.number-merge-game {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 12% 88%, rgb(161 77 47 / 13%), transparent 32%),
|
||||
radial-gradient(circle at 88% 16%, rgb(255 246 219 / 42%), transparent 29%);
|
||||
}
|
||||
|
||||
.number-merge-toolbar {
|
||||
height: 48px;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 66px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr 1fr 36px;
|
||||
grid-template-columns: 32px 1fr 1fr 32px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid rgb(109 66 52 / 10%);
|
||||
border-radius: 21px;
|
||||
background: rgb(255 241 216 / 65%);
|
||||
box-shadow: 0 8px 24px rgb(95 48 31 / 13%);
|
||||
backdrop-filter: blur(14px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.number-merge-toolbar div {
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
grid-template-rows: 10px 20px;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.number-merge-toolbar strong { max-width: 80px; overflow: hidden; font-size: 18px; text-overflow: ellipsis; }
|
||||
.number-merge-toolbar span { line-height: 10px; }
|
||||
.number-merge-toolbar strong { display: block; max-width: 80px; overflow: hidden; font-size: 18px; line-height: 20px; text-overflow: ellipsis; }
|
||||
.number-merge-toolbar__restart { justify-self: end; }
|
||||
|
||||
.number-merge-toolbar .number-merge-toolbar__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.number-merge-board {
|
||||
--board-gap: 7px;
|
||||
--board-padding: 9px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 14px;
|
||||
left: 14px;
|
||||
width: auto;
|
||||
aspect-ratio: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--board-gap);
|
||||
padding: var(--board-padding);
|
||||
border: 1px solid rgb(78 41 32 / 9%);
|
||||
border: 0;
|
||||
border-radius: 21px;
|
||||
background: #80564c;
|
||||
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 17px 28px rgb(101 52 38 / 18%);
|
||||
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 14px 28px rgb(101 52 38 / 15%);
|
||||
transform: translateY(-47%);
|
||||
}
|
||||
|
||||
.number-merge-cell {
|
||||
@@ -638,25 +654,21 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.number-merge-hint { margin: 10px 0 7px; color: #976f61; font-size: 12px; text-align: center; }
|
||||
|
||||
.number-merge-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 38px);
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.number-merge-controls button {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid rgb(97 56 44 / 10%);
|
||||
border-radius: 12px;
|
||||
color: #71483b;
|
||||
background: rgb(255 255 255 / 38%);
|
||||
.number-merge-hint {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
right: 45px;
|
||||
bottom: 27px;
|
||||
left: 45px;
|
||||
margin: 0;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
color: #8e675b;
|
||||
background: rgb(255 241 216 / 52%);
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.number-merge-confirm {
|
||||
|
||||
@@ -106,8 +106,15 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flappy-app" :class="`flappy-app--${flappy.design}`" :aria-label="phone.t('Apps.skyFlappy.name')">
|
||||
<header class="flappy-header">
|
||||
<main
|
||||
class="flappy-app"
|
||||
:class="[
|
||||
`flappy-app--${flappy.design}`,
|
||||
{ 'flappy-app--playing': !flappy.menuOpen && game },
|
||||
]"
|
||||
:aria-label="phone.t('Apps.skyFlappy.name')"
|
||||
>
|
||||
<header v-if="flappy.menuOpen" class="flappy-header">
|
||||
<div><span>{{ phone.t('Apps.skyFlappy.eyebrow') }}</span><h1>{{ phone.t('Apps.skyFlappy.name') }}</h1></div>
|
||||
<button type="button" :aria-label="phone.t(flappy.soundEnabled ? 'Apps.skyFlappy.mute' : 'Apps.skyFlappy.unmute')" @click="toggleSound">
|
||||
<Volume2 v-if="flappy.soundEnabled" :size="18" /><VolumeX v-else :size="18" />
|
||||
@@ -160,10 +167,11 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none;touch-action:manipulation; }
|
||||
.flappy-app--playing { padding:0; }
|
||||
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
|
||||
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button,.flappy-toolbar button{width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}
|
||||
.flappy-menu{height:calc(100% - 55px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center}.flappy-menu__hero{position:relative;width:220px;height:210px;overflow:hidden;border-radius:28px;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 28px #223c6d35,0 18px 32px #101b3b66}.flappy-menu__hero::before{position:absolute;z-index:0;top:22px;left:25px;width:39px;height:39px;border-radius:50%;background:#ffe8a7cc;box-shadow:0 0 18px #fff1b46b;content:""}.flappy-menu__hero::after{position:absolute;z-index:1;bottom:30px;left:-12px;width:68px;height:18px;border-radius:999px;background:#ffffff3d;box-shadow:27px -9px 0 3px #ffffff36,57px 1px 0 -2px #ffffff2c;content:""}.flappy-menu__tower{position:absolute;z-index:3;right:10px;width:42px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 30%),var(--tower));box-shadow:inset 7px 0 0 #ffffff22,inset -6px 0 9px #0003}.flappy-menu__tower::after{position:absolute;left:-8px;width:58px;height:22px;border:3px solid color-mix(in srgb,var(--tower),black 22%);border-radius:7px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 36%),var(--tower));box-shadow:inset 0 4px 0 #ffffff26;content:""}.flappy-menu__tower--top{top:0;height:61px;border-radius:0 0 5px 5px}.flappy-menu__tower--top::after{bottom:-11px}.flappy-menu__tower--bottom{bottom:0;height:67px;border-radius:5px 5px 0 0}.flappy-menu__tower--bottom::after{top:-11px}.flappy-menu__trail{position:absolute;z-index:2;top:102px;left:11px;width:46px;height:4px;border-radius:99px;background:#edfbffb8;box-shadow:13px 12px 0 -1px #e9faff8c,6px -12px 0 -1px #e9faff73;animation:flappy-trail 1s ease-in-out infinite}.sky-bird{position:absolute;z-index:4;width:66px;height:46px;overflow:visible;filter:drop-shadow(0 4px 5px #071d3e66)}.sky-bird :deep(path){stroke-linecap:round;stroke-linejoin:round}.sky-bird :deep(.sky-flappy-bird__far-wing){fill:#2d7188;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__far-wing path:last-child){fill:none;stroke:#8ec6ce;stroke-width:1.4;opacity:.65}.sky-bird :deep(.sky-flappy-bird__tail){fill:#285e78;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__body){fill:url(#sky-bird-body);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__belly){fill:#b7e5df;opacity:.84}.sky-bird :deep(.sky-flappy-bird__neck){fill:#377f98}.sky-bird :deep(.sky-flappy-bird__wing){transform-box:view-box;transform-origin:52px 38px}.sky-bird :deep(.sky-flappy-bird__wing-shape){fill:url(#sky-bird-wing);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__feather){fill:none;stroke:#d4f0ed;stroke-width:1.5;opacity:.78}.sky-bird :deep(.sky-flappy-bird__face){fill:#d8f0e9}.sky-bird :deep(.sky-flappy-bird__eye-ring){fill:#f5fbf7}.sky-bird :deep(.sky-flappy-bird__eye){fill:#102d3d}.sky-bird :deep(.sky-flappy-bird__beak){fill:#f2a84b;stroke:#6f4727;stroke-width:1.5}.sky-bird--hero{top:72px;left:42px;width:100px;height:69px;animation:flappy-hero 1.1s ease-in-out infinite alternate}.sky-bird--hero :deep(.sky-flappy-bird__wing){animation:flappy-bird-glide 1.1s ease-in-out infinite}.sky-bird--hero :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-glide 1.1s ease-in-out infinite}.flappy-menu__copy>span{color:#ffda70;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-menu__copy h2{margin:3px 0 5px;font-size:21px}.flappy-menu__copy p{max-width:275px;margin:0;color:#c0c8df;font-size:10px;line-height:1.4}.flappy-record{width:100%;display:flex;align-items:center;justify-content:space-between;padding:9px 14px;border:1px solid #ffffff14;border-radius:13px;background:#ffffff0c}.flappy-record span{color:#bdc8df;font-size:9px;font-weight:800;text-transform:uppercase}.flappy-record strong{font-size:19px}.flappy-designs{width:100%;display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.flappy-designs button{display:grid;place-items:center;gap:3px;padding:7px 3px;border:1px solid #ffffff12;border-radius:12px;color:#ccd4e9;background:#ffffff0a;font-size:8px}.flappy-designs button.active{border-color:#ffdd72;color:#fff;background:#ffffff1b}.flappy-designs i{width:26px;height:13px;border-radius:8px}.flappy-designs__dawn{background:linear-gradient(90deg,#58d6ee,#ff9b80)}.flappy-designs__neon{background:linear-gradient(90deg,#24255f,#ef55ca)}.flappy-designs__storm{background:linear-gradient(90deg,#71899b,#253750)}.flappy-primary,.flappy-secondary{width:100%;min-height:43px;display:flex;align-items:center;justify-content:center;gap:7px;border-radius:14px;font-size:11px;font-weight:850}.flappy-primary{border:0;color:#173353;background:linear-gradient(135deg,#ffe16c,#ff9d68)}.flappy-secondary{border:1px solid #ffffff18;color:#fff;background:#ffffff0b}.flappy-menu>p,.flappy-game__hint{margin:0;color:#aeb9d2;font-size:9px}
|
||||
.flappy-game{position:relative;height:calc(100% - 55px)}.flappy-toolbar{height:47px;display:grid;grid-template-columns:36px 1fr 1fr 36px;align-items:center;gap:7px}.flappy-toolbar div{display:grid;justify-items:center}.flappy-toolbar span{color:#bac9df;font-size:8px;font-weight:850;text-transform:uppercase}.flappy-toolbar strong{font-size:16px}.flappy-stage{position:relative;width:100%;height:500px;display:block;overflow:hidden;padding:0;border:1px solid #ffffff1c;border-radius:22px;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 35px #15244c55,0 16px 30px #10193477;touch-action:manipulation}.flappy-clouds{position:absolute;z-index:1;inset:0;overflow:hidden;pointer-events:none}.flappy-clouds i{--cloud-scale:1;--cloud-opacity:.34;--cloud-duration:18s;--cloud-delay:0s;position:absolute;left:100%;width:70px;height:18px;border-radius:999px;background:linear-gradient(180deg,#ffffffd9,#eaf7ff9c);box-shadow:0 8px 16px #24376518;opacity:var(--cloud-opacity);animation:cloud-drift var(--cloud-duration) linear var(--cloud-delay) infinite;will-change:transform}.flappy-clouds i::before{position:absolute;bottom:4px;left:12px;width:29px;height:29px;border-radius:50%;background:#f8fcffe6;box-shadow:22px -8px 0 4px #f7fcff,40px 1px 0 -2px #eef9ff;content:""}.flappy-clouds i::after{position:absolute;right:8px;bottom:-3px;left:8px;height:8px;border-radius:50%;background:#bcdff477;filter:blur(4px);content:""}.flappy-clouds i:nth-child(1){--cloud-scale:.7;--cloud-opacity:.3;--cloud-duration:20s;--cloud-delay:-4s;top:9%}.flappy-clouds i:nth-child(2){--cloud-scale:1.05;--cloud-opacity:.4;--cloud-duration:15s;--cloud-delay:-11s;top:22%}.flappy-clouds i:nth-child(3){--cloud-scale:.52;--cloud-opacity:.25;--cloud-duration:23s;--cloud-delay:-17s;top:38%}.flappy-clouds i:nth-child(4){--cloud-scale:.88;--cloud-opacity:.36;--cloud-duration:17s;--cloud-delay:-7s;top:53%}.flappy-clouds i:nth-child(5){--cloud-scale:1.18;--cloud-opacity:.42;--cloud-duration:14s;--cloud-delay:-2s;top:68%}.flappy-clouds i:nth-child(6){--cloud-scale:.62;--cloud-opacity:.27;--cloud-duration:21s;--cloud-delay:-14s;top:78%}.flappy-clouds i:nth-child(7){--cloud-scale:.96;--cloud-opacity:.34;--cloud-duration:16s;--cloud-delay:-9s;top:86%}.flappy-obstacle{position:absolute;z-index:2;top:0;bottom:0}.flappy-obstacle span{position:absolute;right:0;left:0;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 20%),var(--tower));box-shadow:inset -6px 0 8px #0003,0 0 13px #17204e55}.flappy-obstacle span::after{position:absolute;right:-4px;left:-4px;height:14px;border-radius:6px;background:color-mix(in srgb,var(--tower),white 10%);box-shadow:inset 0 3px 0 #ffffff25;content:""}.flappy-obstacle__top{top:0;border-radius:0 0 7px 7px}.flappy-obstacle__top::after{bottom:0}.flappy-obstacle__bottom{bottom:0;border-radius:7px 7px 0 0}.flappy-obstacle__bottom::after{top:0}.sky-bird--player{left:23%;animation:flappy-wing .24s ease-out}.sky-bird--player :deep(.sky-flappy-bird__wing){animation:flappy-bird-flap .24s cubic-bezier(.2,.75,.35,1)}.sky-bird--player :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-flap .24s cubic-bezier(.2,.75,.35,1)}.flappy-ready{position:absolute;z-index:6;top:36%;left:50%;padding:9px 15px;border-radius:17px;background:#15284fbb;font-size:11px;transform:translateX(-50%)}.flappy-horizon{position:absolute;z-index:3;right:0;bottom:0;left:0;height:12px;background:#263a62;box-shadow:0 -5px 14px #ffffff26}.flappy-stage--crashed{animation:flappy-crash .55s ease-out}.flappy-game__hint{margin-top:8px;text-align:center}.flappy-overlay{position:absolute;z-index:12;inset:47px 0 25px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:28px;border-radius:22px;background:#101a38dc;backdrop-filter:blur(7px);text-align:center}.flappy-overlay>svg{color:#ffdc71}.flappy-overlay>span{color:#ffad78;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-overlay h2{margin:0 0 5px;font-size:26px}
|
||||
.flappy-game{position:absolute;inset:0}.flappy-toolbar{position:absolute;z-index:10;top:48px;right:14px;left:14px;height:42px;display:grid;grid-template-columns:36px 1fr 1fr 36px;align-items:center;gap:7px;padding:4px 6px;border:1px solid #ffffff2b;border-radius:22px;background:#263c6da8;box-shadow:0 8px 24px #10193455;backdrop-filter:blur(14px)}.flappy-toolbar div{display:grid;justify-items:center}.flappy-toolbar span{color:#bac9df;font-size:8px;font-weight:850;text-transform:uppercase}.flappy-toolbar strong{font-size:16px}.flappy-toolbar button{width:34px;height:34px;border:0;border-radius:50%;box-shadow:none}.flappy-stage{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:hidden;padding:0;border:0;border-radius:0;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 35px #15244c55;touch-action:manipulation}.flappy-clouds{position:absolute;z-index:1;inset:0;overflow:hidden;pointer-events:none}.flappy-clouds i{--cloud-scale:1;--cloud-opacity:.34;--cloud-duration:18s;--cloud-delay:0s;position:absolute;left:100%;width:70px;height:18px;border-radius:999px;background:linear-gradient(180deg,#ffffffd9,#eaf7ff9c);box-shadow:0 8px 16px #24376518;opacity:var(--cloud-opacity);animation:cloud-drift var(--cloud-duration) linear var(--cloud-delay) infinite;will-change:transform}.flappy-clouds i::before{position:absolute;bottom:4px;left:12px;width:29px;height:29px;border-radius:50%;background:#f8fcffe6;box-shadow:22px -8px 0 4px #f7fcff,40px 1px 0 -2px #eef9ff;content:""}.flappy-clouds i::after{position:absolute;right:8px;bottom:-3px;left:8px;height:8px;border-radius:50%;background:#bcdff477;filter:blur(4px);content:""}.flappy-clouds i:nth-child(1){--cloud-scale:.7;--cloud-opacity:.3;--cloud-duration:20s;--cloud-delay:-4s;top:9%}.flappy-clouds i:nth-child(2){--cloud-scale:1.05;--cloud-opacity:.4;--cloud-duration:15s;--cloud-delay:-11s;top:22%}.flappy-clouds i:nth-child(3){--cloud-scale:.52;--cloud-opacity:.25;--cloud-duration:23s;--cloud-delay:-17s;top:38%}.flappy-clouds i:nth-child(4){--cloud-scale:.88;--cloud-opacity:.36;--cloud-duration:17s;--cloud-delay:-7s;top:53%}.flappy-clouds i:nth-child(5){--cloud-scale:1.18;--cloud-opacity:.42;--cloud-duration:14s;--cloud-delay:-2s;top:68%}.flappy-clouds i:nth-child(6){--cloud-scale:.62;--cloud-opacity:.27;--cloud-duration:21s;--cloud-delay:-14s;top:78%}.flappy-clouds i:nth-child(7){--cloud-scale:.96;--cloud-opacity:.34;--cloud-duration:16s;--cloud-delay:-9s;top:86%}.flappy-obstacle{position:absolute;z-index:2;top:0;bottom:0}.flappy-obstacle span{position:absolute;right:0;left:0;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 20%),var(--tower));box-shadow:inset -6px 0 8px #0003,0 0 13px #17204e55}.flappy-obstacle span::after{position:absolute;right:-4px;left:-4px;height:14px;border-radius:6px;background:color-mix(in srgb,var(--tower),white 10%);box-shadow:inset 0 3px 0 #ffffff25;content:""}.flappy-obstacle__top{top:0;border-radius:0 0 7px 7px}.flappy-obstacle__top::after{bottom:0}.flappy-obstacle__bottom{bottom:0;border-radius:7px 7px 0 0}.flappy-obstacle__bottom::after{top:0}.sky-bird--player{left:23%;animation:flappy-wing .24s ease-out}.sky-bird--player :deep(.sky-flappy-bird__wing){animation:flappy-bird-flap .24s cubic-bezier(.2,.75,.35,1)}.sky-bird--player :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-flap .24s cubic-bezier(.2,.75,.35,1)}.flappy-ready{position:absolute;z-index:6;top:36%;left:50%;padding:9px 15px;border-radius:17px;background:#15284fbb;font-size:11px;transform:translateX(-50%)}.flappy-horizon{position:absolute;z-index:3;right:0;bottom:0;left:0;height:12px;background:#263a62;box-shadow:0 -5px 14px #ffffff26}.flappy-stage--crashed{animation:flappy-crash .55s ease-out}.flappy-game__hint{position:absolute;z-index:6;right:45px;bottom:27px;left:45px;margin:0;padding:7px 10px;border-radius:999px;background:#263c6d91;backdrop-filter:blur(10px);text-align:center;pointer-events:none}.flappy-overlay{position:absolute;z-index:12;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:28px;background:#101a38dc;backdrop-filter:blur(7px);text-align:center}.flappy-overlay>svg{color:#ffdc71}.flappy-overlay>span{color:#ffad78;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-overlay h2{margin:0 0 5px;font-size:26px}
|
||||
.flappy-menu__tower{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff38,inset -6px 0 9px #0004,0 0 18px var(--tower-glow)}
|
||||
.flappy-menu__tower::after{border-color:var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower));box-shadow:inset 0 4px 0 #ffffff45,0 0 14px var(--tower-glow)}
|
||||
.flappy-menu__copy>span{color:#ffdf7d;font-size:14px}
|
||||
@@ -178,9 +186,13 @@ onBeforeUnmount(() => {
|
||||
.flappy-primary,.flappy-secondary{min-height:50px;gap:8px;font-size:18px}
|
||||
.flappy-secondary{border-color:#ffffff2c;background:#ffffff12}
|
||||
.flappy-menu>p,.flappy-game__hint{color:#e1e9f6;font-size:16px;font-weight:700;line-height:1.35}
|
||||
.flappy-toolbar span{color:#e0eafa;font-size:12px;letter-spacing:.35px}
|
||||
.flappy-toolbar strong{font-size:21px}
|
||||
.flappy-stage{border:2px solid #ffffff70;box-shadow:inset 0 0 35px #15244c38,0 16px 30px #10193477}
|
||||
.flappy-toolbar span{color:#e0eafa;font-size:11px;line-height:10px;letter-spacing:.35px}
|
||||
.flappy-toolbar strong{display:block;font-size:19px;line-height:20px}
|
||||
.flappy-toolbar{top:66px;right:18px;left:18px;height:42px;grid-template-columns:32px 1fr 1fr 32px;gap:4px;padding:4px;border-radius:21px;box-sizing:border-box}
|
||||
.flappy-toolbar div{height:32px;display:grid;grid-template-rows:10px 20px;align-content:center;justify-items:center}
|
||||
.flappy-toolbar button{width:32px;height:32px}
|
||||
.flappy-clouds{top:110px}
|
||||
.flappy-stage{border:0;box-shadow:inset 0 0 35px #15244c38}
|
||||
.flappy-obstacle span{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff42,inset -6px 0 8px #0004,0 0 18px var(--tower-glow)}
|
||||
.flappy-obstacle span::after{right:-5px;left:-5px;height:17px;border:2px solid var(--tower-dark);background:linear-gradient(180deg,var(--tower-light),var(--tower));box-shadow:inset 0 4px 0 #ffffff55,0 0 14px var(--tower-glow)}
|
||||
.flappy-ready{padding:11px 18px;border:2px solid #ffffff55;border-radius:19px;background:#15284fe6;font-size:16px;font-weight:850}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Pause,
|
||||
Play,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
|
||||
import {
|
||||
SNAKE_BOARD_HEIGHT,
|
||||
@@ -23,17 +20,7 @@ import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const snake = useSnakeStore()
|
||||
const touchStart = ref<SnakePoint | null>(null)
|
||||
const speedOptions: SnakeSpeed[] = ['relaxed', 'normal', 'fast']
|
||||
const directionButtons: Array<{
|
||||
direction: SnakeDirection
|
||||
icon: typeof ChevronUp
|
||||
}> = [
|
||||
{ direction: 'up', icon: ChevronUp },
|
||||
{ direction: 'left', icon: ChevronLeft },
|
||||
{ direction: 'down', icon: ChevronDown },
|
||||
{ direction: 'right', icon: ChevronRight },
|
||||
]
|
||||
const game = computed(() => snake.game)
|
||||
const boardMotionStyle = computed(() => ({
|
||||
'--snake-motion-duration': `${Math.min(
|
||||
@@ -90,7 +77,9 @@ function stopGameTimer(): void {
|
||||
function syncGameTimer(): void {
|
||||
stopGameTimer()
|
||||
if (snake.game?.status === 'playing') {
|
||||
gameTimer = setInterval(() => snake.tick(), snake.tickMilliseconds)
|
||||
gameTimer = setInterval(() => {
|
||||
snake.tick()
|
||||
}, snake.tickMilliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,16 +88,16 @@ function returnToMenu(): void {
|
||||
snake.showMenu()
|
||||
}
|
||||
|
||||
function startGame(): void {
|
||||
snake.start()
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
const directionByKey: Partial<Record<string, SnakeDirection>> = {
|
||||
ArrowDown: 'down',
|
||||
ArrowLeft: 'left',
|
||||
ArrowRight: 'right',
|
||||
ArrowUp: 'up',
|
||||
a: 'left',
|
||||
d: 'right',
|
||||
s: 'down',
|
||||
w: 'up',
|
||||
}
|
||||
const direction = directionByKey[event.key]
|
||||
|
||||
@@ -125,28 +114,6 @@ function handleKeydown(event: KeyboardEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
function beginSwipe(event: TouchEvent): void {
|
||||
const touch = event.changedTouches[0]
|
||||
touchStart.value = touch ? { x: touch.clientX, y: touch.clientY } : null
|
||||
}
|
||||
|
||||
function endSwipe(event: TouchEvent): void {
|
||||
const start = touchStart.value
|
||||
const touch = event.changedTouches[0]
|
||||
touchStart.value = null
|
||||
if (!start || !touch) return
|
||||
|
||||
const deltaX = touch.clientX - start.x
|
||||
const deltaY = touch.clientY - start.y
|
||||
if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 18) return
|
||||
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
snake.turn(deltaX > 0 ? 'right' : 'left')
|
||||
} else {
|
||||
snake.turn(deltaY > 0 ? 'down' : 'up')
|
||||
}
|
||||
}
|
||||
|
||||
snake.hydrate()
|
||||
watch(
|
||||
() => [snake.game?.status, snake.tickMilliseconds],
|
||||
@@ -162,8 +129,12 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="snake-app" :aria-label="phone.t('Apps.snake.name')">
|
||||
<header class="snake-header">
|
||||
<main
|
||||
class="snake-app"
|
||||
:class="{ 'snake-app--playing': game }"
|
||||
:aria-label="phone.t('Apps.snake.name')"
|
||||
>
|
||||
<header v-if="!game" class="snake-header">
|
||||
<span class="snake-brand">{{ phone.t('Apps.snake.name') }}</span>
|
||||
<div class="snake-score-card">
|
||||
<span>{{ phone.t('Apps.snake.highScore') }}</span>
|
||||
@@ -226,7 +197,7 @@ onBeforeUnmount(() => {
|
||||
{{ phone.t(`Apps.snake.speeds.${speed}`) }}
|
||||
</button>
|
||||
</fieldset>
|
||||
<button type="button" class="snake-primary" @click="snake.start">
|
||||
<button type="button" class="snake-primary" @click="startGame">
|
||||
<Play :size="18" fill="currentColor" />
|
||||
{{ phone.t('Apps.snake.start') }}
|
||||
</button>
|
||||
@@ -243,8 +214,10 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<ChevronLeft :size="18" :stroke-width="2.7" aria-hidden="true" />
|
||||
</button>
|
||||
<span>{{ phone.t('Apps.snake.score') }}</span>
|
||||
<strong>{{ game.score }}</strong>
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.snake.score') }}</span>
|
||||
<strong>{{ game.score }}</strong>
|
||||
</div>
|
||||
<button
|
||||
v-if="game.status !== 'game-over'"
|
||||
type="button"
|
||||
@@ -267,8 +240,6 @@ onBeforeUnmount(() => {
|
||||
class="snake-board"
|
||||
:style="boardMotionStyle"
|
||||
:aria-label="phone.t('Apps.snake.board')"
|
||||
@touchstart.passive="beginSwipe"
|
||||
@touchend.passive="endSwipe"
|
||||
>
|
||||
<span
|
||||
v-for="(_, index) in game.body.slice(1)"
|
||||
@@ -306,7 +277,7 @@ onBeforeUnmount(() => {
|
||||
<template v-else>
|
||||
<span class="snake-overline">{{ phone.t('Apps.snake.score') }} {{ game.score }}</span>
|
||||
<h2>{{ phone.t('Apps.snake.gameOver') }}</h2>
|
||||
<button type="button" class="snake-primary" @click="snake.start">
|
||||
<button type="button" class="snake-primary" @click="startGame">
|
||||
{{ phone.t('Apps.snake.restart') }}
|
||||
</button>
|
||||
<button type="button" class="snake-secondary" @click="returnToMenu">
|
||||
@@ -317,18 +288,6 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<p class="snake-hint">{{ phone.t('Apps.snake.swipeHint') }}</p>
|
||||
<div class="snake-controls" :aria-label="phone.t('Apps.snake.controls')">
|
||||
<button
|
||||
v-for="control in directionButtons"
|
||||
:key="control.direction"
|
||||
type="button"
|
||||
:class="`snake-control--${control.direction}`"
|
||||
:aria-label="phone.t(`Apps.snake.directions.${control.direction}`)"
|
||||
@click="snake.turn(control.direction)"
|
||||
>
|
||||
<component :is="control.icon" :size="24" :stroke-width="2.6" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -348,6 +307,10 @@ onBeforeUnmount(() => {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.snake-app--playing {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.snake-header,
|
||||
.snake-game__meta {
|
||||
display: flex;
|
||||
@@ -507,53 +470,89 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.snake-game {
|
||||
padding-top: 8px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.snake-game__meta {
|
||||
height: 43px;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
top: 66px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 32px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border-radius: 21px;
|
||||
background: rgb(8 22 18 / 58%);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
|
||||
backdrop-filter: blur(14px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.snake-game__meta div {
|
||||
height: 32px;
|
||||
display: grid;
|
||||
grid-template-rows: 10px 20px;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.snake-game__meta span {
|
||||
color: #9fb3a9;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.snake-game__meta strong {
|
||||
font-size: 22px;
|
||||
display: block;
|
||||
font-size: 19px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.snake-game__meta button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #dff6d9;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
background: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
.snake-game__meta .snake-game__pause { margin-left: auto; }
|
||||
.snake-game__meta .snake-game__back { flex: 0 0 auto; }
|
||||
.snake-game__meta .snake-game__pause { justify-self: end; }
|
||||
.snake-game__meta .snake-game__back { justify-self: start; }
|
||||
|
||||
.snake-board {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 18;
|
||||
position: absolute;
|
||||
top: 118px;
|
||||
right: 18px;
|
||||
bottom: 62px;
|
||||
left: 18px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(167 231 157 / 16%);
|
||||
border: 1px solid rgb(145 228 117 / 24%);
|
||||
border-radius: 18px;
|
||||
background-color: #162d26;
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 2%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 2%) 1px, transparent 1px);
|
||||
background-size: calc(100% / 16) calc(100% / 18);
|
||||
box-shadow: inset 0 0 35px rgb(0 0 0 / 20%), 0 17px 35px rgb(0 0 0 / 20%);
|
||||
linear-gradient(rgb(173 233 160 / 4%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(173 233 160 / 4%) 1px, transparent 1px),
|
||||
radial-gradient(circle at 50% 42%, rgb(94 191 91 / 8%), transparent 68%);
|
||||
background-size:
|
||||
calc(100% / 16) calc(100% / 30),
|
||||
calc(100% / 16) calc(100% / 30),
|
||||
100% 100%;
|
||||
box-shadow:
|
||||
inset 0 0 55px rgb(0 0 0 / 24%),
|
||||
inset 0 0 0 1px rgb(213 255 199 / 3%),
|
||||
0 12px 30px rgb(0 0 0 / 20%),
|
||||
0 0 18px rgb(89 196 82 / 7%);
|
||||
}
|
||||
|
||||
.snake-head,
|
||||
@@ -679,36 +678,22 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.snake-hint {
|
||||
margin: 9px 0 6px;
|
||||
color: #71867c;
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
right: 50px;
|
||||
bottom: 27px;
|
||||
left: 50px;
|
||||
margin: 0;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
color: #9eb2a8;
|
||||
background: rgb(8 22 18 / 48%);
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.snake-controls {
|
||||
position: relative;
|
||||
width: 132px;
|
||||
height: 91px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.snake-controls button {
|
||||
position: absolute;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border-radius: 13px;
|
||||
color: #cae6d1;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.snake-control--up { left: 45px; top: 0; }
|
||||
.snake-control--left { left: 0; top: 45px; }
|
||||
.snake-control--down { left: 45px; top: 45px; }
|
||||
.snake-control--right { right: 0; top: 45px; }
|
||||
|
||||
button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { usePhoneStore } from '@/stores/phone'
|
||||
const phone = usePhoneStore()
|
||||
const tower = useTowerStackStore()
|
||||
const game = computed(() => tower.game)
|
||||
const visibleBlocks = computed(() => game.value?.blocks.slice(-9) ?? [])
|
||||
const visibleBlocks = computed(() => game.value?.blocks.slice(-6) ?? [])
|
||||
const placementEffect = ref<'missed' | 'perfect' | 'placed' | null>(null)
|
||||
const fallingBlock = ref<TowerActiveBlock | null>(null)
|
||||
const fallingStyle = ref<CSSProperties>({})
|
||||
@@ -175,8 +175,12 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="tower-app" :aria-label="phone.t('Apps.towerStack.name')">
|
||||
<header class="tower-header">
|
||||
<main
|
||||
class="tower-app"
|
||||
:class="{ 'tower-app--playing': !tower.menuOpen && game }"
|
||||
:aria-label="phone.t('Apps.towerStack.name')"
|
||||
>
|
||||
<header v-if="tower.menuOpen" class="tower-header">
|
||||
<div>
|
||||
<span>{{ phone.t('Apps.towerStack.eyebrow') }}</span>
|
||||
<h1>{{ phone.t('Apps.towerStack.name') }}</h1>
|
||||
@@ -214,7 +218,13 @@ onBeforeUnmount(() => {
|
||||
<Play :size="17" fill="currentColor" />
|
||||
{{ phone.t('Apps.towerStack.resume') }}
|
||||
</button>
|
||||
<button type="button" class="tower-secondary" @click="startGame">
|
||||
<button
|
||||
type="button"
|
||||
:class="game?.status === 'paused' ? 'tower-secondary' : 'tower-primary'"
|
||||
@click="startGame"
|
||||
>
|
||||
<RotateCcw v-if="game" :size="16" aria-hidden="true" />
|
||||
<Play v-else :size="16" fill="currentColor" aria-hidden="true" />
|
||||
{{ phone.t(game ? 'Apps.towerStack.newGame' : 'Apps.towerStack.start') }}
|
||||
</button>
|
||||
<p class="tower-menu__hint">{{ phone.t('Apps.towerStack.tapHint') }}</p>
|
||||
@@ -305,32 +315,34 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; touch-action: manipulation; }
|
||||
.tower-header { height: 55px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.tower-header span { display: block; color: #c1b8f1; font-size: 14px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
|
||||
.tower-header h1 { margin: 1px 0 0; font-size: 32px; line-height: 1; letter-spacing: -0.8px; }
|
||||
.tower-app--playing { padding: 0; }
|
||||
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
|
||||
.tower-header h1 { margin: 1px 0 0; font-size: 27px; line-height: 1; letter-spacing: -0.8px; }
|
||||
.tower-header button, .tower-toolbar button { width: 36px; height: 36px; display: grid; place-items: center; padding: 0; border: 1px solid #ffffff14; border-radius: 12px; color: #f2edff; background: #ffffff0d; }
|
||||
.tower-menu { height: calc(100% - 55px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; text-align: center; }
|
||||
.tower-menu__preview { position: relative; width: 190px; height: 210px; }
|
||||
.tower-menu__preview i { position: absolute; right: 12px; bottom: calc(var(--preview-index) * 25px); left: 25px; height: 29px; border-radius: 7px; background: hsl(calc(var(--preview-index) * 49deg + 5deg) 82% 62%); box-shadow: inset 0 4px 0 #ffffff35, 0 8px 15px #08091c5c; transform: perspective(200px) rotateX(5deg); }
|
||||
.tower-menu__preview i:nth-child(even) { right: 25px; left: 12px; }
|
||||
.tower-menu__preview span { position: absolute; top: 4px; left: 2px; width: 120px; height: 29px; border-radius: 7px; background: #ff6b68; box-shadow: 0 0 24px #ff6b6877; animation: tower-preview-slide 1.5s ease-in-out infinite alternate; }
|
||||
.tower-menu__copy > span { color: #ffcf59; font-size: 14px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.tower-menu__copy h2 { margin: 4px 0 7px; font-size: 30px; }
|
||||
.tower-menu__copy p { max-width: 295px; margin: 0; color: #e0dcf2; font-size: 18px; font-weight: 550; line-height: 1.4; }
|
||||
.tower-menu { height: calc(100% - 50px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; text-align: center; }
|
||||
.tower-menu__preview { position: relative; flex: 0 0 124px; width: 158px; height: 124px; }
|
||||
.tower-menu__preview i { position: absolute; right: 12px; bottom: calc((var(--preview-index) - 1) * 15px); left: 23px; height: 21px; border-radius: 6px; background: hsl(calc(var(--preview-index) * 49deg + 5deg) 82% 62%); box-shadow: inset 0 3px 0 #ffffff35, 0 5px 11px #08091c5c; transform: perspective(200px) rotateX(5deg); }
|
||||
.tower-menu__preview i:nth-child(even) { right: 23px; left: 12px; }
|
||||
.tower-menu__preview span { position: absolute; top: 0; left: 4px; width: 100px; height: 21px; border-radius: 6px; background: #ff6b68; box-shadow: 0 0 20px #ff6b6877; animation: tower-preview-slide 1.5s ease-in-out infinite alternate; }
|
||||
.tower-menu__copy > span { color: #ffcf59; font-size: 10px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.tower-menu__copy h2 { max-width: 270px; margin: 3px auto 5px; font-size: 24px; line-height: 1.08; }
|
||||
.tower-menu__copy p { max-width: 285px; margin: 0; color: #d8d3eb; font-size: 12px; font-weight: 550; line-height: 1.35; }
|
||||
.tower-records { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.tower-records div { display: grid; gap: 3px; padding: 12px 9px; border: 1px solid #ffffff1f; border-radius: 13px; background: #ffffff0d; }
|
||||
.tower-records span { color: #d1caea; font-size: 15px; font-weight: 850; text-transform: uppercase; }
|
||||
.tower-records strong { font-size: 26px; }
|
||||
.tower-primary, .tower-secondary { width: 100%; min-height: 50px; display: flex; align-items: center; justify-content: center; gap: 8px; border-radius: 14px; font-size: 18px; font-weight: 850; }
|
||||
.tower-records div { display: grid; gap: 1px; padding: 8px 9px; border: 1px solid #ffffff1f; border-radius: 13px; background: #ffffff0d; backdrop-filter: blur(8px); }
|
||||
.tower-records span { color: #d1caea; font-size: 9px; font-weight: 850; letter-spacing: .35px; text-transform: uppercase; }
|
||||
.tower-records strong { font-size: 20px; }
|
||||
.tower-primary, .tower-secondary { width: 100%; min-height: 42px; display: flex; align-items: center; justify-content: center; gap: 7px; border-radius: 14px; font-size: 13px; font-weight: 850; }
|
||||
.tower-primary { border: 0; color: #1e1839; background: linear-gradient(135deg, #ffca4f, #ff8760); box-shadow: 0 8px 18px #ff895330; }
|
||||
.tower-secondary { border: 1px solid #ffffff16; color: #eeeaff; background: #ffffff0a; }
|
||||
.tower-menu__hint, .tower-game__hint { margin: 0; color: #e0daf0; font-size: 16px; font-weight: 700; line-height: 1.35; }
|
||||
.tower-game { position: relative; height: calc(100% - 55px); }
|
||||
.tower-toolbar { height: 47px; display: grid; grid-template-columns: 36px 1fr 1fr 36px; align-items: center; gap: 7px; }
|
||||
.tower-toolbar div { display: grid; justify-items: center; line-height: 1.05; }
|
||||
.tower-toolbar span { color: #c9c2e9; font-size: 12px; font-weight: 850; letter-spacing: .35px; text-transform: uppercase; }
|
||||
.tower-toolbar strong { font-size: 21px; }
|
||||
.tower-stage { position: relative; width: 100%; height: 500px; display: block; overflow: hidden; padding: 0; border: 1px solid #ffffff35; border-radius: 22px; background: linear-gradient(#1d1b52, #433078 60%, #8e4c78); box-shadow: inset 0 0 35px #08091d80, 0 16px 28px #08091c66; touch-action: manipulation; }
|
||||
.tower-menu__hint, .tower-game__hint { margin: 0; color: #c9c2e0; font-size: 10px; font-weight: 700; line-height: 1.35; }
|
||||
.tower-game { position: absolute; inset: 0; }
|
||||
.tower-toolbar { position: absolute; z-index: 10; top: 66px; right: 18px; left: 18px; height: 42px; display: grid; grid-template-columns: 32px 1fr 1fr 32px; align-items: center; gap: 4px; padding: 4px; border: 1px solid #ffffff1a; border-radius: 21px; background: #292451a8; box-shadow: 0 8px 24px #08091c52; backdrop-filter: blur(14px); box-sizing: border-box; }
|
||||
.tower-toolbar div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
|
||||
.tower-toolbar span { color: #c9c2e9; font-size: 11px; font-weight: 850; line-height: 10px; letter-spacing: .35px; text-transform: uppercase; }
|
||||
.tower-toolbar strong { display: block; font-size: 19px; line-height: 20px; }
|
||||
.tower-toolbar button { width: 32px; height: 32px; border: 0; border-radius: 50%; box-shadow: none; }
|
||||
.tower-stage { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: hidden; padding: 0; border: 0; border-radius: 0; background: linear-gradient(#1d1b52, #433078 60%, #8e4c78); box-shadow: inset 0 0 35px #08091d80; touch-action: manipulation; }
|
||||
.tower-sky { position: absolute; inset: 0; pointer-events: none; }
|
||||
.tower-sky i { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: #fff; box-shadow: 0 0 7px #c5c2ff; opacity: .65; }
|
||||
.tower-sky i:nth-child(1) { top: 8%; left: 12%; } .tower-sky i:nth-child(2) { top: 17%; left: 72%; } .tower-sky i:nth-child(3) { top: 28%; left: 42%; } .tower-sky i:nth-child(4) { top: 37%; left: 88%; } .tower-sky i:nth-child(5) { top: 46%; left: 18%; } .tower-sky i:nth-child(6) { top: 58%; left: 64%; } .tower-sky i:nth-child(7) { top: 70%; left: 31%; } .tower-sky i:nth-child(8) { top: 11%; left: 91%; } .tower-sky i:nth-child(9) { top: 22%; left: 25%; } .tower-sky i:nth-child(10) { top: 50%; left: 78%; } .tower-sky i:nth-child(11) { top: 64%; left: 8%; } .tower-sky i:nth-child(12) { top: 77%; left: 92%; } .tower-sky i:nth-child(13) { top: 33%; left: 58%; }
|
||||
@@ -343,13 +355,13 @@ onBeforeUnmount(() => {
|
||||
.tower-perfect { position: absolute; z-index: 8; top: 24%; left: 50%; padding: 8px 17px; border-radius: 18px; color: #332149; background: #ffdc65; box-shadow: 0 0 24px #ffcf64aa; font-size: 13px; font-weight: 950; letter-spacing: .8px; transform: translateX(-50%); animation: tower-perfect-pop 650ms ease-out forwards; }
|
||||
.tower-stage--perfect { animation: tower-perfect-glow 500ms ease-out; }
|
||||
.tower-stage--missed { animation: tower-stage-shake 500ms ease-out; }
|
||||
.tower-game__hint { margin-top: 8px; text-align: center; }
|
||||
.tower-overlay { position: absolute; z-index: 15; inset: 47px 0 25px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 28px; border-radius: 22px; color: #f7f2ff; background: #11122bd9; backdrop-filter: blur(7px); text-align: center; }
|
||||
.tower-game__hint { position: absolute; z-index: 6; right: 45px; bottom: 27px; left: 45px; margin: 0; padding: 7px 10px; border-radius: 999px; color: #e0daf0; background: #29245191; backdrop-filter: blur(10px); text-align: center; pointer-events: none; }
|
||||
.tower-overlay { position: absolute; z-index: 15; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 28px; color: #f7f2ff; background: #11122bd9; backdrop-filter: blur(7px); text-align: center; }
|
||||
.tower-overlay > svg { color: #ffbd4d; }
|
||||
.tower-overlay > span { color: #ffad78; font-size: 12px; font-weight: 900; letter-spacing: 1.2px; text-transform: uppercase; }
|
||||
.tower-overlay h2 { margin: 0; font-size: 25px; }
|
||||
.tower-overlay p { margin: -3px 0 5px; color: #c7c1e4; font-size: 14px; }
|
||||
@keyframes tower-preview-slide { from { transform: translateX(0) rotate(-2deg); } to { transform: translateX(66px) rotate(2deg); } }
|
||||
@keyframes tower-preview-slide { from { transform: translateX(0) rotate(-2deg); } to { transform: translateX(48px) rotate(2deg); } }
|
||||
@keyframes tower-land { from { filter: brightness(1.8); transform: translateY(-8px) scaleY(.85); } to { filter: brightness(1); transform: translateY(0) scaleY(1); } }
|
||||
@keyframes tower-active-pulse { from { filter: saturate(1.2) brightness(1.08); } to { filter: saturate(1.45) brightness(1.35); } }
|
||||
@keyframes tower-fall { 0% { opacity: 1; transform: translate(0) rotate(0); } 100% { opacity: 0; transform: translate(45px, 390px) rotate(145deg); } }
|
||||
|
||||
@@ -15,7 +15,7 @@ Config.Command = "phone"
|
||||
|
||||
Config.Phone = {
|
||||
Item = "phone",
|
||||
DevelopmentCommand = true,
|
||||
DevelopmentCommand = false,
|
||||
DeviceName = "iFruit Phone",
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local categories = {}
|
||||
local districts = {}
|
||||
local photo_gradients = {}
|
||||
local item_conditions = { new = true, very_good = true, used = true, defective = true }
|
||||
local price_types = { fixed = true, negotiable = true, free = true }
|
||||
local report_reasons = { prohibited = true, fraud = true, spam = true, offensive = true, other = true }
|
||||
@@ -15,9 +14,6 @@ end
|
||||
for _, district in ipairs(Config.Marketplace.Districts) do
|
||||
districts[district] = true
|
||||
end
|
||||
for _, gradient in ipairs(Config.Marketplace.PhotoGradients) do
|
||||
photo_gradients[gradient] = true
|
||||
end
|
||||
|
||||
local function trim(value)
|
||||
if type(value) ~= "string" then
|
||||
@@ -96,7 +92,7 @@ local function load_images(listing_id)
|
||||
]], { listing_id })
|
||||
end
|
||||
|
||||
local function validate_images(source, imei, images)
|
||||
local function validate_images(source, images)
|
||||
if type(images) ~= "table" or #images > Config.Marketplace.MaxImages then
|
||||
return nil
|
||||
end
|
||||
@@ -104,30 +100,15 @@ local function validate_images(source, imei, images)
|
||||
return {}
|
||||
end
|
||||
|
||||
local owned_media = {
|
||||
["sunset-drive"] = Config.Marketplace.PhotoGradients[1],
|
||||
["ocean-air"] = Config.Marketplace.PhotoGradients[2],
|
||||
["city-lights"] = Config.Marketplace.PhotoGradients[3],
|
||||
["desert-road"] = Config.Marketplace.PhotoGradients[4],
|
||||
}
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `payload` FROM `sky_phone_device_data`
|
||||
WHERE `device_imei` = ? AND `namespace` = 'media'
|
||||
LIMIT 1
|
||||
]], { imei })
|
||||
local media = rows[1] and json.decode(rows[1].payload) or nil
|
||||
for _, capture in ipairs(type(media) == "table" and media.captures or {}) do
|
||||
if type(capture) == "table" and type(capture.id) == "string" and photo_gradients[capture.gradient] then
|
||||
owned_media[capture.id] = capture.gradient
|
||||
end
|
||||
end
|
||||
|
||||
local normalized = {}
|
||||
local seen = {}
|
||||
for index, image in ipairs(images) do
|
||||
local media_id = type(image) == "table" and image.id or nil
|
||||
local gradient = media_id and owned_media[media_id] or nil
|
||||
if type(media_id) ~= "string" or #media_id > 64 or not gradient or seen[media_id] then
|
||||
local numeric_id = tonumber(media_id)
|
||||
local normalized_id = numeric_id and tostring(math.floor(numeric_id)) or nil
|
||||
if not numeric_id or numeric_id < 1 or numeric_id ~= math.floor(numeric_id)
|
||||
or seen[normalized_id]
|
||||
then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected unowned marketplace image from source %s.",
|
||||
@@ -135,8 +116,17 @@ local function validate_images(source, imei, images)
|
||||
)
|
||||
return nil
|
||||
end
|
||||
seen[media_id] = true
|
||||
normalized[index] = { id = media_id, gradient = gradient }
|
||||
local url = SkyPhoneMedia.ResolveOwnedMedia(source, normalized_id, "photo")
|
||||
if not url then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected unowned marketplace image from source %s.",
|
||||
tostring(source)
|
||||
)
|
||||
return nil
|
||||
end
|
||||
seen[normalized_id] = true
|
||||
normalized[index] = { id = normalized_id, gradient = ("url(%s)"):format(json.encode(url)) }
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
@@ -168,7 +158,7 @@ local function validate_listing(source, account, data)
|
||||
return nil, "phone_unavailable"
|
||||
end
|
||||
|
||||
local images = validate_images(source, account.imei, data.images)
|
||||
local images = validate_images(source, data.images)
|
||||
if not images then
|
||||
return nil, "invalid_images"
|
||||
end
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local categories = {}
|
||||
local districts = {}
|
||||
local photo_gradients = {}
|
||||
for _, value in ipairs(Config.LocalPages.Categories) do categories[value] = true end
|
||||
for _, value in ipairs(Config.Marketplace.Districts) do districts[value] = true end
|
||||
for _, value in ipairs(Config.Marketplace.PhotoGradients) do photo_gradients[value] = true end
|
||||
|
||||
local function trim(value)
|
||||
if type(value) ~= "string" then return nil end
|
||||
@@ -33,39 +31,29 @@ local function load_images(post_id)
|
||||
]], { post_id })
|
||||
end
|
||||
|
||||
local function validate_images(source, imei, images)
|
||||
local function validate_images(source, images)
|
||||
if type(images) ~= "table" or #images > Config.LocalPages.MaxImages then return nil end
|
||||
if #images == 0 then return {} end
|
||||
|
||||
local owned_media = {
|
||||
["sunset-drive"] = Config.Marketplace.PhotoGradients[1],
|
||||
["ocean-air"] = Config.Marketplace.PhotoGradients[2],
|
||||
["city-lights"] = Config.Marketplace.PhotoGradients[3],
|
||||
["desert-road"] = Config.Marketplace.PhotoGradients[4],
|
||||
}
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `payload` FROM `sky_phone_device_data`
|
||||
WHERE `device_imei` = ? AND `namespace` = 'media'
|
||||
LIMIT 1
|
||||
]], { imei })
|
||||
local media = rows[1] and json.decode(rows[1].payload) or nil
|
||||
for _, capture in ipairs(type(media) == "table" and media.captures or {}) do
|
||||
if type(capture) == "table" and type(capture.id) == "string" and photo_gradients[capture.gradient] then
|
||||
owned_media[capture.id] = capture.gradient
|
||||
end
|
||||
end
|
||||
|
||||
local normalized = {}
|
||||
local seen = {}
|
||||
for index, image in ipairs(images) do
|
||||
local media_id = type(image) == "table" and image.id or nil
|
||||
local gradient = media_id and owned_media[media_id] or nil
|
||||
if type(media_id) ~= "string" or #media_id > 64 or not gradient or seen[media_id] then
|
||||
local numeric_id = tonumber(media_id)
|
||||
local normalized_id = numeric_id and tostring(math.floor(numeric_id)) or nil
|
||||
if not numeric_id or numeric_id < 1 or numeric_id ~= math.floor(numeric_id)
|
||||
or seen[normalized_id]
|
||||
then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected unowned Local Pages image from source %s.", tostring(source))
|
||||
return nil
|
||||
end
|
||||
seen[media_id] = true
|
||||
normalized[index] = { id = media_id, gradient = gradient }
|
||||
local url = SkyPhoneMedia.ResolveOwnedMedia(source, normalized_id, "photo")
|
||||
if not url then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected unowned Local Pages image from source %s.", tostring(source))
|
||||
return nil
|
||||
end
|
||||
seen[normalized_id] = true
|
||||
normalized[index] = { id = normalized_id, gradient = ("url(%s)"):format(json.encode(url)) }
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
@@ -189,7 +177,7 @@ Bridge.Callbacks.Register("sky_phone:pages:create", function(source, data)
|
||||
then
|
||||
return { success = false, error = "invalid_post" }
|
||||
end
|
||||
local images = validate_images(source, account.imei, data.images)
|
||||
local images = validate_images(source, data.images)
|
||||
if not images then return { success = false, error = "invalid_images" } end
|
||||
local id = new_id()
|
||||
local statements = {{
|
||||
|
||||
Reference in New Issue
Block a user