Merge branch 'benja/app-overhaul' into dev

This commit is contained in:
smx.pusha
2026-08-08 21:53:17 +02:00
31 changed files with 4709 additions and 1314 deletions
+1555 -278
View File
File diff suppressed because it is too large Load Diff
+26 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { kBadge } from 'konsta/vue'
import { Minus } from 'lucide-vue-next'
import { computed, onBeforeUnmount, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
@@ -39,6 +39,7 @@ const darkchat = useDarkChatStore()
const router = useRouter()
const iconFailed = ref(false)
const isDragging = ref(false)
const calendarToday = ref(new Date())
const dragOffset = ref({ x: 0, y: 0 })
const dragStyle = computed(() =>
isDragging.value
@@ -49,6 +50,7 @@ const dragStyle = computed(() =>
)
const suppressClick = ref(false)
let holdTimer: number | undefined
let calendarTimer: number | undefined
let pointerStart = { x: 0, y: 0 }
const unreadCount = computed(() => {
if (props.app.id === 'mail') return mail.counts.unread
@@ -60,6 +62,12 @@ const notificationBadgeColors = {
bg: 'bg-[#ff3b30]',
text: 'text-white',
}
const calendarWeekday = computed(() =>
new Intl.DateTimeFormat(phone.lang, { weekday: 'short' })
.format(calendarToday.value)
.replace(/\.$/, ''),
)
const calendarDay = computed(() => calendarToday.value.getDate())
function launch(event: MouseEvent): void {
if (props.editMode || suppressClick.value) {
@@ -167,8 +175,16 @@ function removeDragListeners(): void {
window.removeEventListener('pointercancel', cancelPointerDrag)
}
onMounted(() => {
if (props.app.id !== 'calendar') return
calendarTimer = window.setInterval(() => {
calendarToday.value = new Date()
}, 60_000)
})
onBeforeUnmount(() => {
clearHold()
if (calendarTimer !== undefined) window.clearInterval(calendarTimer)
removeDragListeners()
})
</script>
@@ -200,10 +216,17 @@ onBeforeUnmount(() => {
<span class="app-icon-anchor" aria-hidden="true">
<span
class="app-icon"
:class="[app.iconClass, { 'app-icon--image': !iconFailed }]"
:class="[
app.iconClass,
{ 'app-icon--image': !iconFailed && app.id !== 'calendar' },
]"
>
<span v-if="app.id === 'calendar'" class="app-icon-calendar">
<strong>{{ calendarWeekday }}</strong>
<b>{{ calendarDay }}</b>
</span>
<img
v-if="!iconFailed"
v-else-if="!iconFailed"
:src="app.iconImage"
alt=""
draggable="false"
@@ -65,4 +65,7 @@ const formattedAmount = computed(() =>
<style scoped>
.citymarkt-offer{width:92%;padding:11px;border:1px solid #ffc92842;border-radius:14px;align-self:flex-start;background:linear-gradient(145deg,#332d19,var(--panel));box-shadow:0 7px 18px #0003}.citymarkt-offer--own{align-self:flex-end}.citymarkt-offer header{display:flex;align-items:center;gap:8px}.citymarkt-offer header>span{width:32px;height:32px;flex:none;border-radius:10px;display:grid;place-items:center;background:var(--yellow);color:#171816}.citymarkt-offer header>div{min-width:0;flex:1}.citymarkt-offer header small,.citymarkt-offer header strong{display:block}.citymarkt-offer header small{color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.02em;text-transform:uppercase}.citymarkt-offer header strong{margin-top:1px;font-size:18px}.citymarkt-offer header i{padding:5px 7px;border-radius:7px;background:#ffc92817;color:var(--yellow);font-size:8px;font-style:normal;font-weight:900;text-transform:uppercase}.citymarkt-offer>p{margin:7px 0 0;color:var(--muted);font-size:9px;line-height:1.35}.citymarkt-offer--accepted{border-color:#54d68173;background:linear-gradient(145deg,#193526,var(--panel))}.citymarkt-offer--accepted header>span{background:#54d681}.citymarkt-offer--accepted header i{background:#54d6811c;color:#67e494}.citymarkt-offer--rejected,.citymarkt-offer--countered{border-color:#ffffff14;filter:saturate(.65)}.citymarkt-offer--rejected header>span,.citymarkt-offer--countered header>span{background:#555750;color:#ddd}.citymarkt-offer--rejected header i,.citymarkt-offer--countered header i{background:#ffffff0c;color:var(--muted)}.citymarkt-offer__actions{margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:6px}.citymarkt-offer__actions button{min-height:35px;padding:7px 6px;border:1px solid #ffffff12;border-radius:10px;display:flex;align-items:center;justify-content:center;gap:4px;background:#ffffff09;color:inherit;font-size:9.5px;font-weight:850;line-height:1.1}.citymarkt-offer__actions button.accept{border:0;background:#54d681;color:#102319}.citymarkt-offer__actions button.reject{grid-column:1/-1;color:#ff8078}:global(.citymarkt--light) .citymarkt-offer{background:linear-gradient(145deg,#fff8d9,#fff);box-shadow:0 7px 18px #0001}:global(.citymarkt--light) .citymarkt-offer--accepted{background:linear-gradient(145deg,#e6faed,#fff)}
.citymarkt-offer{padding:12px}.citymarkt-offer header>span{width:35px;height:35px}.citymarkt-offer header small{font-size:10.5px}.citymarkt-offer header strong{font-size:20px}.citymarkt-offer header i{padding:5px 8px;font-size:9.5px}.citymarkt-offer>p{margin-top:8px;font-size:11.5px}.citymarkt-offer__actions{margin-top:11px;gap:7px}.citymarkt-offer__actions button{min-height:40px;padding:8px;font-size:12px;font-weight:850;gap:5px}.citymarkt-offer__actions button svg{width:15px;height:15px}
.citymarkt-offer:not(.citymarkt-offer--accepted) {
border-color: transparent;
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Check, ChevronDown } from 'lucide-vue-next'
import { kGlass } from 'konsta/vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
type SelectOption = {
@@ -20,7 +21,9 @@ const root = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const highlightedIndex = ref(0)
const selectedLabel = computed(
() => props.options.find((option) => option.value === props.modelValue)?.label ?? '',
() =>
props.options.find((option) => option.value === props.modelValue)?.label ??
'',
)
function open(): void {
@@ -44,7 +47,8 @@ function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
if (!isOpen.value) open()
else select(props.options[highlightedIndex.value]?.value ?? props.modelValue)
else
select(props.options[highlightedIndex.value]?.value ?? props.modelValue)
return
}
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
@@ -52,7 +56,8 @@ function handleKeydown(event: KeyboardEvent): void {
if (!isOpen.value) open()
const direction = event.key === 'ArrowDown' ? 1 : -1
highlightedIndex.value =
(highlightedIndex.value + direction + props.options.length) % props.options.length
(highlightedIndex.value + direction + props.options.length) %
props.options.length
}
function handleOutsidePointer(event: PointerEvent): void {
@@ -60,12 +65,15 @@ function handleOutsidePointer(event: PointerEvent): void {
}
onMounted(() => window.addEventListener('pointerdown', handleOutsidePointer))
onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer))
onUnmounted(() =>
window.removeEventListener('pointerdown', handleOutsidePointer),
)
</script>
<template>
<div ref="root" class="citymarkt-select" @keydown="handleKeydown">
<button
<k-glass
component="button"
class="citymarkt-select__trigger"
type="button"
aria-haspopup="listbox"
@@ -74,7 +82,7 @@ onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer
>
<span>{{ selectedLabel }}</span>
<ChevronDown :size="14" :class="{ open: isOpen }" />
</button>
</k-glass>
<Transition name="citymarkt-select">
<div v-if="isOpen" class="citymarkt-select__menu" role="listbox">
@@ -100,7 +108,105 @@ onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer
</template>
<style scoped>
.citymarkt-select{position:relative;min-width:0}.citymarkt-select__trigger{width:100%;height:36px;padding:0 10px;border:1px solid #ffffff0d;border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:6px;background:var(--panel);color:inherit;font-size:10px;text-align:left}.citymarkt-select__trigger span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.citymarkt-select__trigger svg{flex:none;color:var(--yellow);transition:transform .18s ease}.citymarkt-select__trigger svg.open{transform:rotate(180deg)}.citymarkt-select__menu{position:absolute;z-index:12;top:calc(100% + 5px);right:0;left:0;max-height:176px;padding:4px;border:1px solid #ffffff16;border-radius:11px;overflow-y:auto;background:#292a27;box-shadow:0 12px 28px #0009;scrollbar-width:none}:global(.citymarkt--light) .citymarkt-select__menu{border-color:#00000014;background:#fff;box-shadow:0 12px 28px #0003}.citymarkt-select__menu button{width:100%;min-height:31px;padding:6px 7px;border:0;border-radius:8px;display:flex;align-items:center;justify-content:space-between;gap:5px;background:none;color:var(--muted);font-size:10px;text-align:left}.citymarkt-select__menu button.highlighted{background:#ffffff0b;color:inherit}:global(.citymarkt--light) .citymarkt-select__menu button.highlighted{background:#0000000b}.citymarkt-select__menu button.selected{color:var(--yellow);font-weight:800}.citymarkt-select-enter-active,.citymarkt-select-leave-active{transition:opacity .15s ease,transform .15s ease}.citymarkt-select-enter-from,.citymarkt-select-leave-to{opacity:0;transform:translateY(-4px) scale(.98)}
.citymarkt-select__trigger{height:40px;font-size:13px}
.citymarkt-select__menu button{min-height:36px;padding:7px 8px;font-size:12px}
.citymarkt-select {
position: relative;
min-width: 0;
}
.citymarkt-select__trigger {
width: 100%;
height: 36px;
padding: 0 10px;
border: 0;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
color: inherit;
font-size: 10px;
text-align: left;
}
.citymarkt-select__trigger span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.citymarkt-select__trigger svg {
flex: none;
color: var(--yellow);
transition: transform 0.18s ease;
}
.citymarkt-select__trigger svg.open {
transform: rotate(180deg);
}
.citymarkt-select__menu {
position: absolute;
z-index: 12;
top: calc(100% + 5px);
right: 0;
left: 0;
max-height: 176px;
padding: 4px;
border: 1px solid #ffffff14;
border-radius: 11px;
overflow-y: auto;
background: #292a27;
box-shadow: 0 12px 28px #0009;
scrollbar-width: none;
}
:global(.citymarkt--light) .citymarkt-select__menu {
border-color: #00000012;
background: #fff;
box-shadow: 0 12px 28px #0003;
}
.citymarkt-select__menu button {
width: 100%;
min-height: 31px;
padding: 6px 7px;
border: 0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
background: none;
color: var(--muted);
font-size: 10px;
text-align: left;
}
.citymarkt-select__menu button.highlighted {
background: #ffffff0b;
color: inherit;
}
:global(.citymarkt--light) .citymarkt-select__menu button.highlighted {
background: #0000000b;
}
.citymarkt-select__menu button.selected {
background: #ffffff14;
color: inherit;
font-weight: 800;
}
:global(.citymarkt--light) .citymarkt-select__menu button.selected {
background: #0000000d;
}
.citymarkt-select-enter-active,
.citymarkt-select-leave-active {
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
.citymarkt-select-enter-from,
.citymarkt-select-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
.citymarkt-select__trigger {
height: 40px;
font-size: 13px;
}
.citymarkt-select__menu button {
min-height: 36px;
padding: 7px 8px;
font-size: 12px;
}
</style>
+1 -1
View File
@@ -118,7 +118,7 @@ describe('app registry', () => {
PHONE_APPS.filter((app) => app.category === 'social').map(
(app) => app.id,
),
).toEqual(['local-pages', 'phone', 'darkchat', 'mail'])
).toEqual(['local-pages', 'phone', 'darkchat', 'banking', 'mail'])
expect(
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
@@ -20,6 +20,10 @@ describe('neon drop engine', () => {
it('starts with a valid piece and a complete seven-piece bag', () => {
const game = createNeonDropGame(() => 0.5)
expect(game.status).toBe('playing')
expect(game.board).toHaveLength(17)
expect(game.board.every((row) => row.length === NEON_DROP_COLUMNS)).toBe(
true,
)
expect(new Set([game.active.kind, ...game.queue]).size).toBe(7)
expect(canPlaceNeonDropPiece(game.board, game.active)).toBe(true)
})
@@ -8,7 +8,7 @@ import type {
} from './types'
export const NEON_DROP_COLUMNS = 10
export const NEON_DROP_ROWS = 18
export const NEON_DROP_ROWS = 17
export const NEON_DROP_LINES_PER_LEVEL = 8
const PIECE_KINDS: NeonDropPieceKind[] = ['I', 'J', 'L', 'O', 'S', 'T', 'Z']
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
createSnakeGame,
SNAKE_BOARD_HEIGHT,
SNAKE_BOARD_WIDTH,
stepSnake,
turnSnake,
@@ -27,6 +28,17 @@ describe('Snake engine', () => {
expect(next.body).not.toContainEqual(next.fruit)
})
it('places fruit inside the visible play area', () => {
for (const random of [() => 0, () => 1]) {
const { fruit } = createSnakeGame(random)
expect(fruit.x).toBeGreaterThanOrEqual(0)
expect(fruit.x).toBeLessThan(SNAKE_BOARD_WIDTH)
expect(fruit.y).toBeGreaterThanOrEqual(0)
expect(fruit.y).toBeLessThan(SNAKE_BOARD_HEIGHT)
}
})
it('ignores an immediate reverse direction', () => {
const game = createSnakeGame(() => 0)
@@ -46,6 +58,20 @@ describe('Snake engine', () => {
expect(stepSnake(game).status).toBe('game-over')
})
it('ends the game at the lower edge of the visible play area', () => {
const game: SnakeGameState = {
...createSnakeGame(() => 0),
body: [
{ x: 4, y: SNAKE_BOARD_HEIGHT - 1 },
{ x: 4, y: SNAKE_BOARD_HEIGHT - 2 },
],
direction: 'down',
pendingDirection: 'down',
}
expect(stepSnake(game).status).toBe('game-over')
})
it('ends the game on body collision', () => {
const game: SnakeGameState = {
body: [
+1 -1
View File
@@ -5,7 +5,7 @@ import type {
} from './types'
export const SNAKE_BOARD_WIDTH = 16
export const SNAKE_BOARD_HEIGHT = 18
export const SNAKE_BOARD_HEIGHT = 30
const DIRECTION_VECTORS: Record<SnakeDirection, SnakePoint> = {
up: { x: 0, y: -1 },
+27
View File
@@ -31,4 +31,31 @@ describe('message media handoff', () => {
expect(store.request).toMatchObject({ mediaType: 'video', target: '4205550196' })
expect(store.cancel()).toBe('/apps/messages')
})
it('returns multiple photos and the requesting app context', () => {
const store = useMessageMediaStore()
const secondPhoto = { ...photo, id: 18 }
store.begin('citymarkt:sell', 'photo', '/apps/citymarkt?sell=1', 2, {
title: 'Draft listing',
})
expect(store.completeMany([photo, secondPhoto])).toBe('/apps/citymarkt?sell=1')
expect(store.consumeMany<{ title: string }>('citymarkt:sell')).toEqual({
context: { title: 'Draft listing' },
media: [photo, secondPhoto],
})
})
it('preserves the requesting app context when selection is cancelled', () => {
const store = useMessageMediaStore()
store.begin('local-pages:compose', 'photo', '/apps/local-pages?compose=1', 6, {
title: 'Draft post',
})
expect(store.cancel()).toBe('/apps/local-pages?compose=1')
expect(store.consumeMany<{ title: string }>('local-pages:compose')).toEqual({
context: { title: 'Draft post' },
media: [],
})
})
})
+39 -3
View File
@@ -3,13 +3,20 @@ import { defineStore } from 'pinia'
import type { MediaType, PhoneMedia } from '@/types/media'
type MessageMediaRequest = {
context?: unknown
maxSelection: number
mediaType: MediaType
returnPath: string
target: string
}
type MessageMediaResult = MessageMediaRequest & {
media: PhoneMedia
media: PhoneMedia[]
}
export type MediaSelectionResult<T = unknown> = {
context?: T
media: PhoneMedia[]
}
export const useMessageMediaStore = defineStore('message-media', {
@@ -22,17 +29,37 @@ export const useMessageMediaStore = defineStore('message-media', {
target: string,
mediaType: MediaType,
returnPath = '/apps/messages',
maxSelection = 1,
context?: unknown,
): void {
this.request = { mediaType, returnPath, target }
this.request = {
context,
maxSelection: Math.max(1, Math.floor(maxSelection)),
mediaType,
returnPath,
target,
}
this.result = null
},
cancel(): string {
const returnPath = this.request?.returnPath ?? '/apps/messages'
if (this.request) this.result = { ...this.request, media: [] }
this.request = null
return returnPath
},
complete(media: PhoneMedia): string | null {
if (!this.request || this.request.mediaType !== media.mediaType) return null
return this.completeMany([media])
},
completeMany(media: PhoneMedia[]): string | null {
if (
!this.request ||
media.length < 1 ||
media.length > this.request.maxSelection ||
media.some((entry) => entry.mediaType !== this.request?.mediaType)
) {
return null
}
const returnPath = this.request.returnPath
this.result = { ...this.request, media }
this.request = null
@@ -40,9 +67,18 @@ export const useMessageMediaStore = defineStore('message-media', {
},
consume(target: string): PhoneMedia | null {
if (!this.result || this.result.target !== target) return null
const media = this.result.media
const media = this.result.media[0] ?? null
this.result = null
return media
},
consumeMany<T = unknown>(target: string): MediaSelectionResult<T> | null {
if (!this.result || this.result.target !== target) return null
const result = {
context: this.result.context as T | undefined,
media: [...this.result.media],
}
this.result = null
return result
},
},
})
+2
View File
@@ -744,6 +744,8 @@ const defaultLocales: LocaleTree = {
sortPriceDesc: 'Highest price',
freshOffers: 'Fresh offers',
offers: 'offers',
compactView: 'Compact view',
largeView: 'Large view',
noListings: 'No offers found',
noListingsBody: 'Try another search or category.',
noDistrict: 'No district',
+9 -8
View File
@@ -23,9 +23,9 @@ export type MarketplaceImage = {
export type MarketplaceListingSummary = {
category: MarketplaceCategory
created_at: string
created_at: DatabaseDateValue
district: string | null
expires_at: string
expires_at: DatabaseDateValue
id: string
image: string | null
is_favorite: boolean | number
@@ -35,7 +35,7 @@ export type MarketplaceListingSummary = {
seller_name: string
status: MarketplaceStatus
title: string
updated_at: string
updated_at: DatabaseDateValue
}
export type MarketplaceListing = MarketplaceListingSummary & {
@@ -46,7 +46,7 @@ export type MarketplaceListing = MarketplaceListingSummary & {
reserved_account_id: number | null
revision: number
seller_active: number
seller_since: string
seller_since: DatabaseDateValue
show_phone: boolean | number
}
@@ -75,12 +75,12 @@ export type MarketplaceInquirySummary = {
status: MarketplaceStatus
title: string
unread: number
updated_at: string
updated_at: DatabaseDateValue
}
export type MarketplaceMessage = {
body: string
created_at: string
created_at: DatabaseDateValue
id: number
read_at: string | null
sender_account_id: number
@@ -88,13 +88,13 @@ export type MarketplaceMessage = {
export type MarketplaceOffer = {
amount: number | string
created_at: string
created_at: DatabaseDateValue
id: number
proposer_account_id: number
read_at: string | null
response_read_at: string | null
status: MarketplaceOfferStatus
updated_at: string
updated_at: DatabaseDateValue
}
export type MarketplaceInquiry = {
@@ -124,3 +124,4 @@ export type MarketplaceChat = {
}
export type MarketplaceCounts = { active: number; unread: number }
import type { DatabaseDateValue } from '@/utils/date'
File diff suppressed because it is too large Load Diff
+28 -2
View File
@@ -7,6 +7,7 @@ import {
kSegmentedButton,
} from 'konsta/vue'
import {
ArrowLeft,
Images,
RefreshCw,
RotateCcwSquare,
@@ -38,7 +39,7 @@ const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
const requestedMessageMedia = computed<MediaType | null>(() => {
const value = route.query.messageAttachment
const value = route.query.mediaAttachment ?? route.query.messageAttachment
return value === 'photo' || value === 'video' ? value : null
})
const mode = ref<MediaType>(requestedMessageMedia.value ?? 'photo')
@@ -205,6 +206,10 @@ function capture(): void {
void requestPhoto()
}
function cancelMediaSelection(): void {
void router.replace(messageMedia.cancel())
}
function setMode(nextMode: MediaType): void {
if (recording.value || savingVideo.value) return
mode.value = nextMode
@@ -413,7 +418,17 @@ onBeforeUnmount(() => {
</div>
<header class="camera-topbar">
<button
v-if="requestedMessageMedia"
class="camera-picker-back"
type="button"
:aria-label="phone.t('Common.back')"
@click="cancelMediaSelection"
>
<ArrowLeft :size="20" />
</button>
<k-fab
v-if="!requestedMessageMedia"
component="button"
type="button"
class="camera-control"
@@ -489,7 +504,7 @@ onBeforeUnmount(() => {
router.push({
path: '/apps/photos',
query: requestedMessageMedia
? { messageAttachment: requestedMessageMedia }
? { mediaAttachment: requestedMessageMedia }
: undefined,
})
"
@@ -671,6 +686,17 @@ onBeforeUnmount(() => {
.camera-control {
--color-primary: transparent;
}
.camera-picker-back {
width: 44px;
height: 44px;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
background: #1c1c1ecc;
color: #fff;
backdrop-filter: blur(16px);
}
.camera-control svg {
width: 21px;
height: 21px;
File diff suppressed because one or more lines are too long
+61 -1
View File
@@ -41,9 +41,13 @@ const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
const requestedMessageMedia = computed<GalleryFilter | null>(() => {
const value = route.query.messageAttachment
const value = route.query.mediaAttachment ?? route.query.messageAttachment
return value === 'photo' || value === 'video' ? value : null
})
const multipleSelection = computed(
() => requestedMessageMedia.value !== null && (messageMedia.request?.maxSelection ?? 1) > 1,
)
const selectedMediaIds = ref<number[]>([])
const media = ref<PhoneMedia[]>([])
const filter = ref<GalleryFilter>(requestedMessageMedia.value ?? 'all')
const loading = ref(true)
@@ -203,6 +207,14 @@ function observeMore(): void {
function openMedia(entry: PhoneMedia): void {
if (requestedMessageMedia.value) {
if (multipleSelection.value) {
const index = selectedMediaIds.value.indexOf(entry.id)
if (index >= 0) selectedMediaIds.value.splice(index, 1)
else if (selectedMediaIds.value.length < (messageMedia.request?.maxSelection ?? 1)) {
selectedMediaIds.value.push(entry.id)
}
return
}
const returnPath = messageMedia.complete(entry)
if (returnPath) void router.replace(returnPath)
return
@@ -214,6 +226,15 @@ function openMedia(entry: PhoneMedia): void {
imagePan.value = { x: 0, y: 0 }
}
function completeMultipleSelection(): void {
const selectedMedia = selectedMediaIds.value.flatMap((id) => {
const entry = media.value.find((item) => item.id === id)
return entry ? [entry] : []
})
const returnPath = messageMedia.completeMany(selectedMedia)
if (returnPath) void router.replace(returnPath)
}
function cancelMessageSelection(): void {
void router.replace(messageMedia.cancel())
}
@@ -352,6 +373,15 @@ onBeforeUnmount(() => {
@click="cancelMessageSelection"
/>
</template>
<template v-if="multipleSelection" #right>
<k-link
component="button"
:disabled="!selectedMediaIds.length"
@click="completeMultipleSelection"
>
{{ phone.t('Common.done') }}
</k-link>
</template>
</k-navbar>
<div class="gallery-content">
@@ -375,6 +405,7 @@ onBeforeUnmount(() => {
v-for="entry in media"
:key="entry.id"
class="gallery-tile"
:class="{ 'gallery-tile--selected': selectedMediaIds.includes(entry.id) }"
type="button"
:aria-label="
phone.t(
@@ -401,6 +432,12 @@ onBeforeUnmount(() => {
<span v-if="entry.mediaType === 'video'" class="gallery-video-badge">
<Play :size="16" fill="currentColor" />
</span>
<span
v-if="multipleSelection && selectedMediaIds.includes(entry.id)"
class="gallery-selection-badge"
>
{{ selectedMediaIds.indexOf(entry.id) + 1 }}
</span>
</button>
<span
v-if="hasMore"
@@ -594,6 +631,29 @@ onBeforeUnmount(() => {
border: 0;
background: #d1d1d6;
}
.gallery-tile--selected::after {
content: '';
position: absolute;
inset: 0;
border: 3px solid #0a84ff;
pointer-events: none;
}
.gallery-selection-badge {
position: absolute;
top: 7px;
right: 7px;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 2px solid #fff;
border-radius: 50%;
background: #0a84ff;
color: #fff;
font-size: 12px;
font-weight: 700;
box-shadow: 0 2px 6px #0006;
}
.gallery-grid--fill .gallery-tile {
height: 100%;
}
+387 -68
View File
@@ -11,31 +11,51 @@ import {
Images,
MapPin,
Plus,
Search,
Send,
Store,
Trash2,
UserRound,
X,
} from 'lucide-vue-next'
import {
kButton,
kGlass,
kIcon,
kNavbar,
kPage,
kSearchbar,
kTabbar,
kTabbarLink,
kToolbarPane,
} from 'konsta/vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue'
import { useAccountStore } from '@/stores/account'
import { useMediaStore } from '@/stores/media'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePagesStore } from '@/stores/pages'
import { usePhoneStore } from '@/stores/phone'
import type { PagesCategory, PagesPost } from '@/types/pages'
type SelectedPhoto = { background: string; id: string }
type ComposeDraft = {
body: string
category: Exclude<PagesCategory, 'citymarkt'>
district: string
images: string[]
title: string
}
type MediaContext = { draft: ComposeDraft; photos: SelectedPhoto[] }
type Screen = 'main' | 'detail' | 'compose'
type Tab = 'feed' | 'create' | 'profile'
const phone = usePhoneStore()
const account = useAccountStore()
const media = useMediaStore()
const messageMedia = useMessageMediaStore()
const pages = usePagesStore()
const route = useRoute()
const router = useRouter()
const screen = ref<Screen>('main')
const tab = ref<Tab>('feed')
@@ -46,9 +66,8 @@ const search = ref('')
const category = ref<string>('all')
const feedback = ref('')
const reactionPending = ref(false)
const photoSource = ref<'camera' | 'gallery' | null>(null)
const cameraFlash = ref(false)
const draft = ref({
const pickedPhotos = ref<SelectedPhoto[]>([])
const draft = ref<ComposeDraft>({
body: '',
category: 'recommendation' as Exclude<PagesCategory, 'citymarkt'>,
district: 'los_santos',
@@ -76,10 +95,10 @@ const displayedPosts = computed(() => tab.value === 'profile'
: pages.items)
const isAuthenticated = computed(() => Boolean(account.email))
const selectedPhotos = computed(() => draft.value.images
.map((id) => media.photos.find((photo) => photo.id === id))
.map((id) => pickedPhotos.value.find((photo) => photo.id === id))
.filter((photo) => photo !== undefined))
const selectedImages = computed(() => selectedPhotos.value.map((photo, index) => ({
gradient: photo.gradient,
gradient: photo.background,
media_id: photo.id,
sort_order: index + 1,
})))
@@ -110,6 +129,15 @@ async function loadFeed(): Promise<void> {
await pages.load({ category: category.value, search: search.value })
}
function updateSearch(event: Event): void {
search.value = (event.target as HTMLInputElement).value
}
function clearSearch(): void {
search.value = ''
void loadFeed()
}
async function selectTab(next: Tab): Promise<void> {
if (next === 'create') {
if (!isAuthenticated.value) {
@@ -134,19 +162,29 @@ async function openPost(post: PagesPost): Promise<void> {
function togglePhoto(id: string): void {
const index = draft.value.images.indexOf(id)
if (index >= 0) draft.value.images.splice(index, 1)
else if (draft.value.images.length < 6) draft.value.images.push(id)
else showFeedback('Apps.localPages.photoLimit')
if (index >= 0) {
draft.value.images.splice(index, 1)
pickedPhotos.value = pickedPhotos.value.filter((photo) => photo.id !== id)
}
}
function capturePhoto(): void {
if (draft.value.images.length >= 6) {
function openMediaApp(app: 'camera' | 'photos'): void {
const remaining = 6 - draft.value.images.length
if (remaining < 1) {
showFeedback('Apps.localPages.photoLimit')
return
}
cameraFlash.value = true
draft.value.images.push(media.capture().id)
window.setTimeout(() => (cameraFlash.value = false), 120)
messageMedia.begin(
'local-pages:compose',
'photo',
'/apps/local-pages?compose=1',
app === 'photos' ? remaining : 1,
{
draft: { ...draft.value, images: [...draft.value.images] },
photos: [...pickedPhotos.value],
} satisfies MediaContext,
)
void router.push({ path: `/apps/${app}`, query: { mediaAttachment: 'photo' } })
}
async function publish(): Promise<void> {
@@ -166,7 +204,7 @@ async function publish(): Promise<void> {
return
}
draft.value = { body: '', category: 'recommendation', district: 'los_santos', images: [], title: '' }
photoSource.value = null
pickedPhotos.value = []
tab.value = 'feed'
screen.value = 'main'
showFeedback('Apps.localPages.published')
@@ -225,40 +263,72 @@ function openCityMarktListing(): void {
})
}
onMounted(() => void loadFeed())
onMounted(() => {
const selection = messageMedia.consumeMany<MediaContext>('local-pages:compose')
if (selection) {
if (selection.context) {
draft.value = selection.context.draft
pickedPhotos.value = selection.context.photos
}
for (const media of selection.media) {
const id = String(media.id)
if (draft.value.images.includes(id) || draft.value.images.length >= 6) continue
draft.value.images.push(id)
pickedPhotos.value.push({ background: `url(${JSON.stringify(media.url)})`, id })
}
}
if (route.query.compose === '1') screen.value = 'compose'
void loadFeed()
})
</script>
<template>
<main class="pages" :class="{ 'pages--light': !phone.isDarkMode }">
<k-page
component="main"
class="pages pb-safe-24"
:class="{ 'pages--light': !phone.isDarkMode }"
:colors="{ bgIos: 'bg-transparent' }"
>
<template v-if="screen === 'main'">
<header class="pages__header">
<div>
<span class="pages__brand">
<MapPin :size="14" />
{{ phone.t(tab === 'feed' ? 'Apps.localPages.eyebrow' : 'Apps.localPages.name') }}
</span>
<h1>{{ phone.t(tab === 'feed' ? 'Apps.localPages.name' : 'Apps.localPages.profile') }}</h1>
</div>
</header>
<k-navbar
class="pages-navbar"
:subtitle="phone.t(tab === 'feed' ? 'Apps.localPages.eyebrow' : 'Apps.localPages.name')"
:title="phone.t(tab === 'feed' ? 'Apps.localPages.name' : 'Apps.localPages.profile')"
/>
<section class="pages__content">
<template v-if="tab === 'feed'">
<div class="pages__hero"><div><small>{{ phone.t('Apps.localPages.cityPulse') }}</small><strong>{{ phone.t('Apps.localPages.heroTitle') }}</strong><span>{{ phone.t('Apps.localPages.heroBody') }}</span></div><MapPin :size="40" /></div>
<form class="pages__search" @submit.prevent="loadFeed"><Search :size="17" /><input v-model="search" :placeholder="phone.t('Apps.localPages.searchPlaceholder')" /><button>{{ phone.t('Apps.localPages.search') }}</button></form>
<k-glass class="pages-hero-glass">
<div class="pages__hero"><div><small>{{ phone.t('Apps.localPages.cityPulse') }}</small><strong>{{ phone.t('Apps.localPages.heroTitle') }}</strong><span>{{ phone.t('Apps.localPages.heroBody') }}</span></div><MapPin :size="40" /></div>
</k-glass>
<k-searchbar
component="form"
class="pages-searchbar"
:value="search"
:placeholder="phone.t('Apps.localPages.searchPlaceholder')"
@input="updateSearch"
@clear="clearSearch"
@submit.prevent="loadFeed"
/>
<CityMarktSelect :model-value="category" :options="categoryOptions" @change="(value) => { category = value; loadFeed() }" />
</template>
<template v-else>
<div v-if="!isAuthenticated" class="pages__empty"><UserRound :size="42" /><strong>{{ phone.t('Apps.localPages.signInTitle') }}</strong><span>{{ phone.t('Apps.localPages.signInBody') }}</span></div>
<template v-else>
<div class="pages__profile"><span>{{ account.email.charAt(0).toUpperCase() }}</span><div><small>{{ phone.t('Apps.localPages.localCreator') }}</small><strong>@{{ account.email.split('@')[0] }}</strong><b>{{ pages.ownItems.length }} {{ phone.t('Apps.localPages.posts') }}</b></div></div>
<div class="pages__segmented"><button :class="{ active: profileMode === 'own' }" @click="profileMode = 'own'">{{ phone.t('Apps.localPages.myPosts') }}</button><button :class="{ active: profileMode === 'saved' }" @click="profileMode = 'saved'">{{ phone.t('Apps.localPages.saved') }}</button></div>
<k-glass class="pages-profile-glass">
<div class="pages__profile"><span>{{ account.email.charAt(0).toUpperCase() }}</span><div><small>{{ phone.t('Apps.localPages.localCreator') }}</small><strong>@{{ account.email.split('@')[0] }}</strong><b>{{ pages.ownItems.length }} {{ phone.t('Apps.localPages.posts') }}</b></div></div>
</k-glass>
<k-glass class="pages-segmented-glass">
<div class="pages__segmented"><button :class="{ active: profileMode === 'own' }" @click="profileMode = 'own'">{{ phone.t('Apps.localPages.myPosts') }}</button><button :class="{ active: profileMode === 'saved' }" @click="profileMode = 'saved'">{{ phone.t('Apps.localPages.saved') }}</button></div>
</k-glass>
</template>
</template>
<div v-if="pages.isLoading" class="pages__empty">{{ phone.t('Common.loading') }}</div>
<div v-else-if="isAuthenticated || tab === 'feed'" class="pages__feed">
<article v-for="post in displayedPosts" :key="post.id" class="pages__post">
<k-glass v-for="post in displayedPosts" :key="post.id" class="pages-post-glass">
<article class="pages__post">
<button class="pages__post-open" type="button" @click="openPost(post)">
<div class="pages__post-head"><span>{{ post.author_name.charAt(0).toUpperCase() }}</span><div><strong>@{{ post.author_name }}</strong><small><MapPin :size="10" /> {{ post.district ? phone.t(`Apps.citymarkt.districts.${post.district}`) : phone.t('Apps.localPages.allLosSantos') }} · {{ relativeDate(post.created_at) }}</small></div><i>{{ label('categories', post.category) }}</i></div>
<div v-if="post.image" class="pages__cover" :style="{ background: post.image }"><b v-if="post.images.length > 1">1 / {{ post.images.length }}</b></div>
@@ -269,20 +339,53 @@ onMounted(() => void loadFeed())
<span v-if="post.source_type === 'citymarkt'"><Store :size="14" /> CityMarkt</span>
<button type="button" :class="{ active: post.is_saved }" :disabled="reactionPending" :aria-label="phone.t('Apps.localPages.save')" @click="reactToPost(post, 'save')"><Bookmark :size="15" :fill="post.is_saved ? 'currentColor' : 'none'" /> {{ phone.t('Apps.localPages.save') }}</button>
</div>
</article>
</article>
</k-glass>
<div v-if="!displayedPosts.length" class="pages__empty"><Compass :size="38" /><strong>{{ phone.t('Apps.localPages.noPosts') }}</strong><span>{{ phone.t('Apps.localPages.noPostsBody') }}</span></div>
</div>
</section>
<nav class="pages__tabbar">
<button :class="{ active: tab === 'feed' }" @click="selectTab('feed')"><span><Compass :size="20" /></span>{{ phone.t('Apps.localPages.discover') }}</button>
<button class="create" @click="selectTab('create')"><span><Plus :size="23" /></span>{{ phone.t('Apps.localPages.create') }}</button>
<button :class="{ active: tab === 'profile' }" @click="selectTab('profile')"><span><UserRound :size="20" /></span>{{ phone.t('Apps.localPages.profile') }}</button>
</nav>
<k-tabbar
component="nav"
icons
labels
class="bottom-0 left-0 fixed"
inner-class="!w-full !max-w-none !gap-0 !px-1"
:aria-label="phone.t('Apps.localPages.name')"
>
<k-toolbar-pane class="pages__tab-pane">
<k-tabbar-link
component="button"
:active="tab === 'feed'"
:link-props="{ class: 'pages-tab-button', type: 'button' }"
@click="selectTab('feed')"
>
<template #label><span class="pages__tab-label">{{ phone.t('Apps.localPages.discover') }}</span></template>
<template #icon><k-icon><Compass :size="20" /></k-icon></template>
</k-tabbar-link>
<k-tabbar-link
component="button"
:link-props="{ class: 'pages-tab-button', type: 'button' }"
@click="selectTab('create')"
>
<template #label><span class="pages__tab-label">{{ phone.t('Apps.localPages.create') }}</span></template>
<template #icon><span class="pages__tab-icon pages__tab-icon--create"><k-icon><Plus :size="21" /></k-icon></span></template>
</k-tabbar-link>
<k-tabbar-link
component="button"
:active="tab === 'profile'"
:link-props="{ class: 'pages-tab-button', type: 'button' }"
@click="selectTab('profile')"
>
<template #label><span class="pages__tab-label">{{ phone.t('Apps.localPages.profile') }}</span></template>
<template #icon><k-icon><UserRound :size="20" /></k-icon></template>
</k-tabbar-link>
</k-toolbar-pane>
</k-tabbar>
</template>
<section v-else-if="screen === 'detail' && selected" class="pages__detail">
<header><button @click="screen = 'main'"><ArrowLeft :size="20" /></button><strong>{{ phone.t('Apps.localPages.post') }}</strong><button v-if="selected.is_owner" class="danger" @click="removePost"><Trash2 :size="18" /></button><button v-else @click="react('save')"><Bookmark :size="18" :fill="selected.is_saved ? 'currentColor' : 'none'" /></button></header>
<header><k-button component="button" clear rounded @click="screen = 'main'"><ArrowLeft :size="20" /></k-button><strong>{{ phone.t('Apps.localPages.post') }}</strong><k-button v-if="selected.is_owner" component="button" clear rounded class="danger" @click="removePost"><Trash2 :size="18" /></k-button><k-button v-else component="button" clear rounded @click="react('save')"><Bookmark :size="18" :fill="selected.is_saved ? 'currentColor' : 'none'" /></k-button></header>
<div class="pages__detail-scroll">
<div v-if="selected.images.length" class="pages__gallery" :style="{ background: selected.images[galleryIndex]?.gradient }"><button v-if="selected.images.length > 1" @click="moveGallery(-1)"><ChevronLeft /></button><button v-if="selected.images.length > 1" @click="moveGallery(1)"><ChevronRight /></button><span>{{ galleryIndex + 1 }} / {{ selected.images.length }}</span></div>
<article><div class="pages__author"><span>{{ selected.author_name.charAt(0).toUpperCase() }}</span><div><strong>@{{ selected.author_name }}</strong><small>{{ relativeDate(selected.created_at) }}</small></div><i>{{ label('categories', selected.category) }}</i></div><h1>{{ selected.title }}</h1><p>{{ selected.body }}</p><div class="pages__location"><MapPin :size="17" /><div><small>{{ phone.t('Apps.localPages.location') }}</small><strong>{{ selected.district ? phone.t(`Apps.citymarkt.districts.${selected.district}`) : phone.t('Apps.localPages.allLosSantos') }}</strong></div></div><button v-if="selected.source_type === 'citymarkt'" class="pages__market-link" @click="openCityMarktListing"><Store :size="18" /><span><small>{{ phone.t('Apps.localPages.sharedFrom') }}</small><strong>{{ phone.t('Apps.localPages.openCityMarkt') }}</strong></span><b v-if="selected.citymarkt_price">${{ Number(selected.citymarkt_price).toLocaleString() }}</b></button></article>
@@ -291,26 +394,44 @@ onMounted(() => void loadFeed())
</section>
<section v-else class="pages__compose">
<header><button @click="screen = 'main'"><X :size="20" /></button><div><small>{{ phone.t('Apps.localPages.newPost') }}</small><strong>{{ phone.t('Apps.localPages.shareWithCity') }}</strong></div><button :disabled="!canPublish" @click="publish"><Send :size="16" />{{ phone.t('Apps.localPages.publish') }}</button></header>
<k-navbar
class="pages-create-navbar"
center-title
left-class="pages-create-action pages-create-action--close !w-11 !min-w-11 !max-w-11 !h-11 !p-0 !rounded-full"
right-class="pages-create-action pages-create-action--publish !min-w-[58px] !h-11 !p-0 !rounded-full"
:title="phone.t('Apps.localPages.shareWithCity')"
:subtitle="phone.t('Apps.localPages.newPost')"
>
<template #left>
<button class="pages-create-close" type="button" :aria-label="phone.t('Common.close')" @click="screen = 'main'">
<X :size="20" />
</button>
</template>
<template #right>
<button class="pages-create-publish" type="button" :disabled="!canPublish" @click="publish">
{{ phone.t('Apps.localPages.publish') }}
</button>
</template>
</k-navbar>
<div class="pages__compose-scroll">
<label>{{ phone.t('Apps.localPages.title') }} <span :class="{ valid: draft.title.trim().length >= 5 }">{{ draft.title.trim().length }}/80 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '5' }) }}</span><input v-model="draft.title" maxlength="80" :placeholder="phone.t('Apps.localPages.titlePlaceholder')" /></label>
<label>{{ phone.t('Apps.localPages.body') }} <span :class="{ valid: draft.body.trim().length >= 10 }">{{ draft.body.trim().length }}/1500 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '10' }) }}</span><textarea v-model="draft.body" maxlength="1500" :placeholder="phone.t('Apps.localPages.bodyPlaceholder')" /></label>
<label>{{ phone.t('Apps.localPages.title') }} <span :class="{ valid: draft.title.trim().length >= 5 }">{{ draft.title.trim().length }}/80 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '5' }) }}</span><k-glass class="pages__field-glass"><input v-model="draft.title" maxlength="80" :placeholder="phone.t('Apps.localPages.titlePlaceholder')" /></k-glass></label>
<label>{{ phone.t('Apps.localPages.body') }} <span :class="{ valid: draft.body.trim().length >= 10 }">{{ draft.body.trim().length }}/1500 · {{ phone.t('Apps.citymarkt.minimumCharacters', { minimum: '10' }) }}</span><k-glass class="pages__field-glass pages__field-glass--textarea"><textarea v-model="draft.body" maxlength="1500" :placeholder="phone.t('Apps.localPages.bodyPlaceholder')" /></k-glass></label>
<div class="pages__form-row"><label>{{ phone.t('Apps.localPages.category') }}<CityMarktSelect :model-value="draft.category" :options="composeCategoryOptions" @change="(value) => draft.category = value as typeof draft.category" /></label><label>{{ phone.t('Apps.localPages.location') }}<CityMarktSelect :model-value="draft.district" :options="districtOptions" @change="(value) => draft.district = value" /></label></div>
<section class="pages__photos">
<ImagePlus :size="30" />
<h2>{{ phone.t('Apps.citymarkt.addPhotos') }}</h2>
<p>{{ phone.t('Apps.citymarkt.addPhotosBody') }}</p>
<div class="pages__photo-actions">
<button type="button" @click="photoSource = 'gallery'">
<k-glass><button type="button" @click="openMediaApp('photos')">
<span><Images :size="20" /></span>
<strong>{{ phone.t('Apps.citymarkt.chooseGallery') }}</strong>
<small>{{ phone.t('Apps.citymarkt.chooseGalleryBody') }}</small>
</button>
<button type="button" @click="photoSource = 'camera'">
</button></k-glass>
<k-glass><button type="button" @click="openMediaApp('camera')">
<span><Camera :size="20" /></span>
<strong>{{ phone.t('Apps.citymarkt.takePhotos') }}</strong>
<small>{{ phone.t('Apps.citymarkt.takePhotosBody') }}</small>
</button>
</button></k-glass>
</div>
<div class="pages__selected-heading">
<strong>{{ phone.t('Apps.citymarkt.selectedPhotos') }}</strong>
@@ -330,27 +451,9 @@ onMounted(() => void loadFeed())
</div>
</section>
</div>
<div v-if="photoSource" class="pages__photo-source">
<header>
<div><small>{{ phone.t('Apps.citymarkt.addPhotos') }}</small><strong>{{ phone.t(photoSource === 'gallery' ? 'Apps.citymarkt.gallery' : 'Apps.citymarkt.camera') }}</strong></div>
<span>{{ draft.images.length }} / 6</span>
<button type="button" :aria-label="phone.t('Common.close')" @click="photoSource = null"><X :size="18" /></button>
</header>
<div v-if="photoSource === 'gallery'" class="pages__photo-picker">
<button v-for="photo in media.photos" :key="photo.id" type="button" :class="{ active: draft.images.includes(photo.id) }" :style="{ background: photo.gradient }" @click="togglePhoto(photo.id)"><i v-if="draft.images.includes(photo.id)">{{ draft.images.indexOf(photo.id) + 1 }}</i></button>
</div>
<div v-else class="pages__capture">
<div class="pages__viewfinder" :style="{ background: media.photos[0]?.gradient }">
<i v-for="corner in ['tl', 'tr', 'bl', 'br']" :key="corner" :class="`corner-${corner}`" />
<span class="pages__camera-flash" :class="{ active: cameraFlash }" />
</div>
<p>{{ phone.t('Apps.citymarkt.cameraHint') }}</p>
<button class="pages__shutter" type="button" :aria-label="phone.t('Apps.citymarkt.takePhoto')" @click="capturePhoto"><Camera :size="23" /></button>
</div>
</div>
</section>
<Transition name="toast"><div v-if="feedback" class="pages__toast">{{ feedback }}</div></Transition>
</main>
</k-page>
</template>
<style scoped>
@@ -395,4 +498,220 @@ onMounted(() => void loadFeed())
.pages__selected-heading span,.pages__photo-source>header small,.pages__photo-source>header>span{font-size:11px}
.pages__selected-strip button i,.pages__photo-picker i{font-size:10px}
.pages__toast{font-size:12px}
.pages {
--color-primary: var(--yellow);
position: relative;
height: 100%;
padding: 0;
background: #12171b !important;
}
.pages--light {
background: #fbfbf6 !important;
}
.pages-navbar {
--k-safe-area-top: 46px;
position: absolute;
z-index: 5;
top: 0;
right: 0;
left: 0;
}
.pages__content {
position: absolute;
inset: 0;
height: auto;
padding: 108px 13px 112px;
}
.pages-hero-glass,
.pages-profile-glass,
.pages-segmented-glass,
.pages-post-glass {
width: 100%;
border-radius: 17px;
}
.pages-hero-glass {
margin-bottom: 10px;
}
.pages__hero {
height: 105px;
margin: 0;
background: transparent;
box-shadow: none;
}
.pages-searchbar {
margin-bottom: 8px;
}
.pages-profile-glass {
margin: 3px 0 10px;
}
.pages__profile {
margin: 0;
background: transparent;
}
.pages-segmented-glass {
padding: 4px;
border-radius: 13px;
}
.pages__segmented {
padding: 0;
background: transparent;
}
.pages-post-glass {
overflow: hidden;
}
.pages__post {
background: transparent;
box-shadow: none;
}
.pages__tab-pane {
width: 100% !important;
max-width: none;
margin-right: auto;
margin-left: auto;
flex: none;
justify-content: space-around;
gap: 2px;
padding: 0 4px;
}
.pages__tab-label {
display: block;
max-width: 52px;
overflow: hidden;
font-size: 9.5px;
line-height: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.pages__tab-pane) {
width: 100% !important;
max-width: none;
margin-right: auto;
margin-left: auto;
flex: none;
justify-content: space-around;
}
:global(.pages-tab-button) {
width: 20% !important;
min-width: 0 !important;
max-width: 58px !important;
flex: 0 0 20% !important;
padding-right: 3px !important;
padding-left: 3px !important;
}
.pages-create-navbar {
--k-safe-area-top: 46px;
position: absolute;
z-index: 5;
top: 0;
right: 0;
left: 0;
}
.pages-create-action {
height: 44px;
border-radius: 9999px;
}
.pages-create-action--close {
width: 44px;
}
.pages-create-action--publish {
min-width: 58px;
}
.pages-create-close,
.pages-create-publish {
width: 100%;
height: 44px;
padding: 0;
border: 0;
appearance: none;
background: transparent;
color: inherit;
}
.pages-create-close {
display: grid;
place-items: center;
}
.pages-create-publish {
min-width: 58px;
padding: 0 13px;
display: grid;
place-items: center;
font-size: 12px;
font-weight: 800;
}
.pages-create-publish:disabled {
opacity: 0.38;
}
.pages__compose-scroll {
position: absolute;
top: 104px;
right: 0;
bottom: 0;
left: 0;
height: auto;
padding-bottom: 35px;
}
.pages__field-glass {
min-height: 44px;
margin-top: 5px;
border-radius: 9999px;
overflow: hidden;
}
.pages__field-glass--textarea {
min-height: 116px;
border-radius: 18px;
}
.pages__field-glass > input,
.pages__field-glass > textarea {
width: 100%;
min-height: 44px;
margin: 0 !important;
padding: 11px 14px !important;
border: 0 !important;
outline: 0;
background: transparent !important;
color: inherit;
font-size: 13px !important;
}
.pages__field-glass > textarea {
min-height: 116px;
resize: none;
line-height: 1.45;
}
.pages__photo-actions > * {
min-width: 0;
border-radius: 14px;
}
.pages__photo-actions > * > button {
width: 100%;
min-height: 118px;
padding: 11px 9px;
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
background: transparent;
}
.pages__photo-actions > * > button > span {
width: 34px;
height: 34px;
margin-bottom: 8px;
border-radius: 11px;
display: grid;
place-items: center;
color: var(--yellow);
}
.pages__tab-icon {
position: relative;
display: grid;
place-items: center;
}
.pages__tab-icon--create {
width: 38px;
height: 30px;
margin-top: -4px;
border-radius: 10px;
background: var(--yellow);
color: #17191a;
box-shadow: 0 4px 12px #00000030;
}
</style>
+52 -27
View File
@@ -123,8 +123,12 @@ onBeforeUnmount(() => {
</script>
<template>
<main class="memory-app" :aria-label="phone.t('Apps.memory.name')">
<header class="memory-header">
<main
class="memory-app"
:class="{ 'memory-app--playing': game }"
:aria-label="phone.t('Apps.memory.name')"
>
<header v-if="!game" class="memory-header">
<div>
<span>{{ phone.t('Apps.memory.eyebrow') }}</span>
<h1>{{ phone.t('Apps.memory.name') }}</h1>
@@ -278,6 +282,10 @@ onBeforeUnmount(() => {
user-select: none;
}
.memory-app--playing {
padding: 0;
}
.memory-header {
height: 52px;
display: flex;
@@ -405,50 +413,67 @@ onBeforeUnmount(() => {
.memory-difficulty__best { align-self: start; color: #74698f; font-size: 11.5px; line-height: 1.2; }
.memory-difficulties svg { grid-column: 3; grid-row: 1 / 3; color: #7658c7; }
.memory-game { padding-top: 5px; }
.memory-stats {
height: 50px;
display: grid;
grid-template-columns: 30px auto auto 1fr;
align-items: center;
gap: 11px;
.memory-game {
position: absolute;
inset: 0;
}
.memory-stats div { display: grid; }
.memory-stats span { color: #74698f; font-size: 10.5px; font-weight: 800; text-transform: uppercase; }
.memory-stats strong { font-size: 19px; line-height: 1.05; }
.memory-stats button { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 14px; font-weight: 800; }
.memory-stats {
position: absolute;
z-index: 7;
top: 66px;
right: 18px;
left: 18px;
height: 42px;
display: grid;
grid-template-columns: 32px 1fr 1fr auto;
align-items: center;
gap: 4px;
padding: 4px;
border: 1px solid rgb(105 79 160 / 10%);
border-radius: 21px;
background: rgb(246 241 255 / 64%);
box-shadow: 0 8px 24px rgb(75 52 121 / 13%);
backdrop-filter: blur(14px);
box-sizing: border-box;
}
.memory-stats div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
.memory-stats span { color: #74698f; font-size: 10.5px; font-weight: 800; line-height: 10px; text-transform: uppercase; }
.memory-stats strong { display: block; font-size: 19px; line-height: 20px; }
.memory-stats button { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 13px; font-weight: 800; }
.memory-stats .memory-menu-button {
width: 30px;
height: 30px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
justify-self: start;
padding: 0;
border: 1px solid rgb(105 79 160 / 10%);
border-radius: 10px;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 48%);
box-shadow: 0 3px 8px rgb(75 52 121 / 8%);
cursor: pointer;
}
.memory-board {
position: relative;
position: absolute;
inset: 0;
display: grid;
gap: 8px;
padding: 9px;
border: 1px solid rgb(101 77 153 / 9%);
border-radius: 20px;
background: rgb(255 255 255 / 31%);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 50%);
align-content: center;
padding: 108px 17px 28px;
border: 0;
border-radius: 0;
background:
radial-gradient(circle at 12% 88%, rgb(135 105 207 / 12%), transparent 31%),
radial-gradient(circle at 88% 16%, rgb(255 255 255 / 54%), transparent 28%);
perspective: 900px;
}
.memory-board--small { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.memory-board--medium,
.memory-board--large { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.memory-board--large { gap: 6px; padding: 7px; }
.memory-board--large { gap: 6px; padding-right: 15px; padding-left: 15px; }
.memory-game-card {
aspect-ratio: 0.82;
@@ -545,7 +570,7 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 20px;
border-radius: 0;
background: rgb(244 239 255 / 90%);
backdrop-filter: blur(6px);
text-align: center;
+78 -19
View File
@@ -198,8 +198,12 @@ onBeforeUnmount(() => {
</script>
<template>
<main class="minesweeper-app" :aria-label="phone.t('Apps.minesweeper.name')">
<header class="minesweeper-header">
<main
class="minesweeper-app"
:class="{ 'minesweeper-app--playing': !minesweeper.menuOpen && game }"
:aria-label="phone.t('Apps.minesweeper.name')"
>
<header v-if="minesweeper.menuOpen" class="minesweeper-header">
<div>
<span>{{ phone.t('Apps.minesweeper.eyebrow') }}</span>
<h1>{{ phone.t('Apps.minesweeper.name') }}</h1>
@@ -420,6 +424,10 @@ onBeforeUnmount(() => {
user-select: none;
}
.minesweeper-app--playing {
padding: 0;
}
.minesweeper-header {
height: 55px;
display: flex;
@@ -524,22 +532,59 @@ onBeforeUnmount(() => {
.minesweeper-difficulties small { align-self: start; color: #658a89; font-size: 8px; }
.minesweeper-long-press { margin: 0; color: #628887; font-size: 9px; }
.minesweeper-game { position: relative; padding-top: 5px; }
.minesweeper-toolbar { height: 46px; display: grid; grid-template-columns: 36px 1fr 1fr 36px; align-items: center; gap: 7px; }
.minesweeper-toolbar div { display: grid; justify-items: center; line-height: 1.05; }
.minesweeper-toolbar span { color: #5a8484; font-size: 8px; font-weight: 800; text-transform: uppercase; }
.minesweeper-toolbar strong { font-size: 16px; }
.minesweeper-game {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 12% 88%, rgb(26 129 130 / 14%), transparent 32%),
radial-gradient(circle at 88% 16%, rgb(238 255 249 / 45%), transparent 29%);
}
.minesweeper-toolbar {
position: absolute;
z-index: 10;
top: 66px;
right: 18px;
left: 18px;
height: 42px;
display: grid;
grid-template-columns: 32px 1fr 1fr 32px;
align-items: center;
gap: 4px;
padding: 4px;
border: 1px solid rgb(23 83 87 / 10%);
border-radius: 21px;
background: rgb(226 249 242 / 65%);
box-shadow: 0 8px 24px rgb(23 73 75 / 13%);
backdrop-filter: blur(14px);
box-sizing: border-box;
}
.minesweeper-toolbar div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
.minesweeper-toolbar span { color: #5a8484; font-size: 10px; font-weight: 800; line-height: 10px; text-transform: uppercase; }
.minesweeper-toolbar strong { display: block; font-size: 18px; line-height: 20px; }
.minesweeper-toolbar .minesweeper-toolbar__icon {
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
box-shadow: none;
}
.minesweeper-board {
position: relative;
position: absolute;
top: 50%;
right: 14px;
left: 14px;
display: grid;
grid-template-columns: repeat(var(--minesweeper-columns), minmax(0, 1fr));
gap: 3px;
padding: 7px;
border: 1px solid rgb(16 78 82 / 10%);
border: 0;
border-radius: 18px;
background: #187a7e;
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 15px 27px rgb(20 79 81 / 18%);
transform: translateY(-45%);
}
.minesweeper-cell {
@@ -582,8 +627,7 @@ onBeforeUnmount(() => {
.minesweeper-game--exploding::after {
position: absolute;
z-index: 9;
inset: 46px 0 0;
border-radius: 18px;
inset: 0;
background: #ffb341;
content: "";
pointer-events: none;
@@ -767,13 +811,13 @@ onBeforeUnmount(() => {
}
@keyframes minesweeper-board-shake {
0%, 100% { transform: translate(0); }
12% { transform: translate(-7px, 3px) rotate(-1deg); }
24% { transform: translate(6px, -4px) rotate(1deg); }
38% { transform: translate(-5px, -2px); }
52% { transform: translate(4px, 3px); }
68% { transform: translate(-2px, -2px); }
82% { transform: translate(2px, 1px); }
0%, 100% { transform: translate(0, -45%); }
12% { transform: translate(-7px, calc(-45% + 3px)) rotate(-1deg); }
24% { transform: translate(6px, calc(-45% - 4px)) rotate(1deg); }
38% { transform: translate(-5px, calc(-45% - 2px)); }
52% { transform: translate(4px, calc(-45% + 3px)); }
68% { transform: translate(-2px, calc(-45% - 2px)); }
82% { transform: translate(2px, calc(-45% + 1px)); }
}
@keyframes minesweeper-bomb-drop {
@@ -866,7 +910,22 @@ onBeforeUnmount(() => {
.minesweeper-secondary { position: relative; z-index: 1; min-width: 155px; min-height: 40px; border-radius: 13px; font-size: 11px; font-weight: 850; pointer-events: auto; }
.minesweeper-primary { border: 0; color: #104d51; background: #8ce3d2; }
.minesweeper-secondary { border: 1px solid rgb(255 255 255 / 13%); color: #e6faf5; background: rgb(255 255 255 / 7%); }
.minesweeper-game__hint { margin: 9px 0 0; color: #668c8c; font-size: 9px; text-align: center; }
.minesweeper-game__hint {
position: absolute;
z-index: 6;
right: 45px;
bottom: 27px;
left: 45px;
margin: 0;
padding: 7px 10px;
border-radius: 999px;
color: #668c8c;
background: rgb(226 249 242 / 52%);
backdrop-filter: blur(10px);
font-size: 9px;
text-align: center;
pointer-events: none;
}
button:active { transform: scale(0.96); }
+97 -117
View File
@@ -1,14 +1,9 @@
<script setup lang="ts">
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ChevronLeft,
ChevronsDown,
Pause,
Play,
RotateCcw,
RotateCw,
Trophy,
Volume2,
VolumeX,
@@ -201,8 +196,12 @@ onBeforeUnmount(() => {
</script>
<template>
<main class="neon-drop-app" :aria-label="phone.t('Apps.neonDrop.name')">
<header class="neon-header">
<main
class="neon-drop-app"
:class="{ 'neon-drop-app--playing': !neon.menuOpen && game }"
:aria-label="phone.t('Apps.neonDrop.name')"
>
<header v-if="neon.menuOpen" class="neon-header">
<div>
<span>{{ phone.t('Apps.neonDrop.eyebrow') }}</span>
<h1>{{ phone.t('Apps.neonDrop.name') }}</h1>
@@ -335,51 +334,11 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.neonDrop.level') }}</span
><strong>{{ game.level }}</strong>
<div class="neon-level">
<i :style="{ height: `${levelProgress * 100}%` }"></i>
<i :style="{ width: `${levelProgress * 100}%` }"></i>
</div>
</aside>
</div>
<div
class="neon-controls"
:aria-label="phone.t('Apps.neonDrop.controls')"
>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.left')"
@click="move(-1)"
>
<ArrowLeft :size="18" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.rotate')"
@click="rotate"
>
<RotateCw :size="18" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.right')"
@click="move(1)"
>
<ArrowRight :size="18" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.softDrop')"
@click="softDrop"
>
<ArrowDown :size="18" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.hardDrop')"
@click="hardDrop"
>
<ChevronsDown :size="18" />
</button>
</div>
<p class="neon-hint">{{ phone.t('Apps.neonDrop.gameHint') }}</p>
<div v-if="game.status === 'paused'" class="neon-overlay">
@@ -421,6 +380,9 @@ onBeforeUnmount(() => {
user-select: none;
touch-action: manipulation;
}
.neon-drop-app--playing {
padding: 0;
}
.neon-header {
height: 54px;
display: flex;
@@ -613,42 +575,70 @@ onBeforeUnmount(() => {
background: #ffffff0a;
}
.neon-game {
position: relative;
height: calc(100% - 54px);
position: absolute;
inset: 0;
}
.neon-toolbar {
position: absolute;
z-index: 10;
top: 66px;
right: 18px;
left: 18px;
height: 42px;
display: grid;
grid-template-columns: 35px 1fr 1fr 35px;
grid-template-columns: 32px 1fr 1fr 32px;
align-items: center;
gap: 5px;
gap: 4px;
padding: 4px;
border: 1px solid #7cf6e72b;
border-radius: 21px;
background: #101a38a8;
box-shadow: 0 8px 24px #0008;
backdrop-filter: blur(14px);
box-sizing: border-box;
}
.neon-toolbar span {
font-size: 11px;
line-height: 10px;
}
.neon-toolbar div {
text-align: center;
height: 32px;
display: grid;
grid-template-rows: 10px 20px;
align-content: center;
justify-items: center;
}
.neon-toolbar strong {
font-size: 21px;
display: block;
font-size: 19px;
line-height: 20px;
}
.neon-toolbar button {
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
box-shadow: none;
}
.neon-play-area {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 5px;
position: absolute;
inset: 0;
}
.neon-board {
width: 218px;
height: 414px;
position: absolute;
top: 156px;
right: 0;
bottom: 76px;
left: 0;
display: grid;
grid-template-columns: repeat(10, 1fr);
grid-template-rows: repeat(18, 1fr);
gap: 1.5px;
padding: 5px;
border: 1px solid #65f4e43b;
border-radius: 13px;
background: #06091af2;
box-shadow:
inset 0 0 25px #000b,
0 12px 26px #0008;
grid-template-rows: repeat(17, 1fr);
gap: 1px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
touch-action: none;
}
.neon-cell {
@@ -700,31 +690,35 @@ onBeforeUnmount(() => {
animation: neon-clear 0.24s ease-out;
}
.neon-side {
width: 70px;
display: flex;
flex-direction: column;
position: absolute;
top: 110px;
right: 14px;
left: 14px;
height: 40px;
display: grid;
grid-template-columns: auto 40px auto auto 1fr;
align-items: center;
gap: 9px;
padding: 10px 4px 11px;
border: 1px solid #65f4e448;
border-radius: 13px;
background: linear-gradient(180deg, #1420478f, #090d24a3);
box-shadow: inset 0 0 16px #53e8d90a;
gap: 8px;
padding: 0 6px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.neon-side > strong {
font-size: 28px;
font-size: 21px;
}
.neon-preview {
width: 62px;
height: 62px;
width: 40px;
height: 40px;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 2px;
padding: 5px;
border: 1px solid #ffffff35;
border-radius: 10px;
background: #ffffff08;
gap: 1px;
padding: 3px;
border: 0;
border-radius: 0;
background: transparent;
}
.neon-preview i[class*='neon-piece--'] {
border: 1px solid #ffffff68;
@@ -734,63 +728,49 @@ onBeforeUnmount(() => {
}
.neon-level {
position: relative;
width: 12px;
height: 221px;
width: 100%;
height: 6px;
overflow: hidden;
border-radius: 8px;
background: #ffffff0c;
}
.neon-level i {
position: absolute;
right: 0;
top: 0;
bottom: 0;
left: 0;
border-radius: 8px;
background: linear-gradient(#73a7ff, #71f5e5);
box-shadow: 0 0 9px #69f4e5;
transition: height 0.25s;
}
.neon-controls {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 5px;
margin-top: 9px;
}
.neon-controls button {
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid #ffffff16;
border-radius: 11px;
color: #dffefd;
background: #ffffff0b;
}
.neon-controls button:last-child {
color: #071225;
background: linear-gradient(135deg, #ffe265, #ff9167);
}
.neon-controls svg {
transform: scale(1.2);
transition: width 0.25s;
}
.neon-hint {
margin: 7px 0 0;
position: absolute;
z-index: 6;
right: 45px;
bottom: 27px;
left: 45px;
margin: 0;
padding: 7px 10px;
border-radius: 999px;
color: #d6deed;
font-size: 16px;
background: #101a3891;
backdrop-filter: blur(10px);
font-size: 10px;
font-weight: 700;
text-align: center;
pointer-events: none;
}
.neon-overlay {
position: absolute;
z-index: 10;
inset: 42px 0 19px;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 25px;
border-radius: 17px;
background: #080c21e8;
backdrop-filter: blur(6px);
text-align: center;
+68 -56
View File
@@ -1,9 +1,5 @@
<script setup lang="ts">
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ChevronLeft,
RotateCcw,
Volume2,
@@ -30,16 +26,6 @@ const canResume = computed(
const currentHighest = computed(() =>
Math.max(0, ...(numberMerge.game?.tiles.map((tile) => tile.value) ?? [])),
)
const directionButtons: Array<{
direction: NumberMergeDirection
icon: typeof ArrowUp
}> = [
{ direction: 'left', icon: ArrowLeft },
{ direction: 'up', icon: ArrowUp },
{ direction: 'down', icon: ArrowDown },
{ direction: 'right', icon: ArrowRight },
]
function formatScore(value: number): string {
return new Intl.NumberFormat('en-US').format(value)
}
@@ -139,8 +125,12 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
</script>
<template>
<main class="number-merge-app" :aria-label="phone.t('Apps.numberMerge.name')">
<header class="number-merge-header">
<main
class="number-merge-app"
:class="{ 'number-merge-app--playing': !numberMerge.menuOpen && game }"
:aria-label="phone.t('Apps.numberMerge.name')"
>
<header v-if="numberMerge.menuOpen" class="number-merge-header">
<div>
<span>{{ phone.t('Apps.numberMerge.eyebrow') }}</span>
<h1>{{ phone.t('Apps.numberMerge.name') }}</h1>
@@ -317,17 +307,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
</div>
<p class="number-merge-hint">{{ phone.t('Apps.numberMerge.swipeHint') }}</p>
<div class="number-merge-controls" :aria-label="phone.t('Apps.numberMerge.controls')">
<button
v-for="control in directionButtons"
:key="control.direction"
type="button"
:aria-label="phone.t(`Apps.numberMerge.directions.${control.direction}`)"
@click="move(control.direction)"
>
<component :is="control.icon" :size="19" :stroke-width="2.5" aria-hidden="true" />
</button>
</div>
</section>
<div v-if="confirmNewGame" class="number-merge-confirm" role="dialog" aria-modal="true">
@@ -360,6 +339,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
user-select: none;
}
.number-merge-app--playing {
padding: 0;
}
.number-merge-header {
height: 55px;
display: flex;
@@ -508,40 +491,73 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
}
.number-merge-how-to small { display: block; color: #8b6559; font-size: 11px; line-height: 1.35; }
.number-merge-game { padding-top: 5px; }
.number-merge-game {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 12% 88%, rgb(161 77 47 / 13%), transparent 32%),
radial-gradient(circle at 88% 16%, rgb(255 246 219 / 42%), transparent 29%);
}
.number-merge-toolbar {
height: 48px;
position: absolute;
z-index: 10;
top: 66px;
right: 18px;
left: 18px;
height: 42px;
display: grid;
grid-template-columns: 36px 1fr 1fr 36px;
grid-template-columns: 32px 1fr 1fr 32px;
align-items: center;
gap: 7px;
gap: 4px;
padding: 4px;
border: 1px solid rgb(109 66 52 / 10%);
border-radius: 21px;
background: rgb(255 241 216 / 65%);
box-shadow: 0 8px 24px rgb(95 48 31 / 13%);
backdrop-filter: blur(14px);
box-sizing: border-box;
}
.number-merge-toolbar div {
min-width: 0;
height: 32px;
display: grid;
grid-template-rows: 10px 20px;
align-content: center;
justify-items: center;
line-height: 1.05;
}
.number-merge-toolbar strong { max-width: 80px; overflow: hidden; font-size: 18px; text-overflow: ellipsis; }
.number-merge-toolbar span { line-height: 10px; }
.number-merge-toolbar strong { display: block; max-width: 80px; overflow: hidden; font-size: 18px; line-height: 20px; text-overflow: ellipsis; }
.number-merge-toolbar__restart { justify-self: end; }
.number-merge-toolbar .number-merge-toolbar__icon {
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
box-shadow: none;
}
.number-merge-board {
--board-gap: 7px;
--board-padding: 9px;
position: relative;
width: 100%;
position: absolute;
top: 50%;
right: 14px;
left: 14px;
width: auto;
aspect-ratio: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--board-gap);
padding: var(--board-padding);
border: 1px solid rgb(78 41 32 / 9%);
border: 0;
border-radius: 21px;
background: #80564c;
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 17px 28px rgb(101 52 38 / 18%);
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 14px 28px rgb(101 52 38 / 15%);
transform: translateY(-47%);
}
.number-merge-cell {
@@ -638,25 +654,21 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
font-weight: 800;
}
.number-merge-hint { margin: 10px 0 7px; color: #976f61; font-size: 12px; text-align: center; }
.number-merge-controls {
display: grid;
grid-template-columns: repeat(4, 38px);
justify-content: center;
gap: 7px;
}
.number-merge-controls button {
width: 38px;
height: 38px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid rgb(97 56 44 / 10%);
border-radius: 12px;
color: #71483b;
background: rgb(255 255 255 / 38%);
.number-merge-hint {
position: absolute;
z-index: 6;
right: 45px;
bottom: 27px;
left: 45px;
margin: 0;
padding: 7px 10px;
border-radius: 999px;
color: #8e675b;
background: rgb(255 241 216 / 52%);
backdrop-filter: blur(10px);
font-size: 10px;
text-align: center;
pointer-events: none;
}
.number-merge-confirm {
+18 -6
View File
@@ -106,8 +106,15 @@ onBeforeUnmount(() => {
</script>
<template>
<main class="flappy-app" :class="`flappy-app--${flappy.design}`" :aria-label="phone.t('Apps.skyFlappy.name')">
<header class="flappy-header">
<main
class="flappy-app"
:class="[
`flappy-app--${flappy.design}`,
{ 'flappy-app--playing': !flappy.menuOpen && game },
]"
:aria-label="phone.t('Apps.skyFlappy.name')"
>
<header v-if="flappy.menuOpen" class="flappy-header">
<div><span>{{ phone.t('Apps.skyFlappy.eyebrow') }}</span><h1>{{ phone.t('Apps.skyFlappy.name') }}</h1></div>
<button type="button" :aria-label="phone.t(flappy.soundEnabled ? 'Apps.skyFlappy.mute' : 'Apps.skyFlappy.unmute')" @click="toggleSound">
<Volume2 v-if="flappy.soundEnabled" :size="18" /><VolumeX v-else :size="18" />
@@ -160,10 +167,11 @@ onBeforeUnmount(() => {
<style scoped>
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none;touch-action:manipulation; }
.flappy-app--playing { padding:0; }
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button,.flappy-toolbar button{width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}
.flappy-menu{height:calc(100% - 55px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center}.flappy-menu__hero{position:relative;width:220px;height:210px;overflow:hidden;border-radius:28px;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 28px #223c6d35,0 18px 32px #101b3b66}.flappy-menu__hero::before{position:absolute;z-index:0;top:22px;left:25px;width:39px;height:39px;border-radius:50%;background:#ffe8a7cc;box-shadow:0 0 18px #fff1b46b;content:""}.flappy-menu__hero::after{position:absolute;z-index:1;bottom:30px;left:-12px;width:68px;height:18px;border-radius:999px;background:#ffffff3d;box-shadow:27px -9px 0 3px #ffffff36,57px 1px 0 -2px #ffffff2c;content:""}.flappy-menu__tower{position:absolute;z-index:3;right:10px;width:42px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 30%),var(--tower));box-shadow:inset 7px 0 0 #ffffff22,inset -6px 0 9px #0003}.flappy-menu__tower::after{position:absolute;left:-8px;width:58px;height:22px;border:3px solid color-mix(in srgb,var(--tower),black 22%);border-radius:7px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 36%),var(--tower));box-shadow:inset 0 4px 0 #ffffff26;content:""}.flappy-menu__tower--top{top:0;height:61px;border-radius:0 0 5px 5px}.flappy-menu__tower--top::after{bottom:-11px}.flappy-menu__tower--bottom{bottom:0;height:67px;border-radius:5px 5px 0 0}.flappy-menu__tower--bottom::after{top:-11px}.flappy-menu__trail{position:absolute;z-index:2;top:102px;left:11px;width:46px;height:4px;border-radius:99px;background:#edfbffb8;box-shadow:13px 12px 0 -1px #e9faff8c,6px -12px 0 -1px #e9faff73;animation:flappy-trail 1s ease-in-out infinite}.sky-bird{position:absolute;z-index:4;width:66px;height:46px;overflow:visible;filter:drop-shadow(0 4px 5px #071d3e66)}.sky-bird :deep(path){stroke-linecap:round;stroke-linejoin:round}.sky-bird :deep(.sky-flappy-bird__far-wing){fill:#2d7188;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__far-wing path:last-child){fill:none;stroke:#8ec6ce;stroke-width:1.4;opacity:.65}.sky-bird :deep(.sky-flappy-bird__tail){fill:#285e78;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__body){fill:url(#sky-bird-body);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__belly){fill:#b7e5df;opacity:.84}.sky-bird :deep(.sky-flappy-bird__neck){fill:#377f98}.sky-bird :deep(.sky-flappy-bird__wing){transform-box:view-box;transform-origin:52px 38px}.sky-bird :deep(.sky-flappy-bird__wing-shape){fill:url(#sky-bird-wing);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__feather){fill:none;stroke:#d4f0ed;stroke-width:1.5;opacity:.78}.sky-bird :deep(.sky-flappy-bird__face){fill:#d8f0e9}.sky-bird :deep(.sky-flappy-bird__eye-ring){fill:#f5fbf7}.sky-bird :deep(.sky-flappy-bird__eye){fill:#102d3d}.sky-bird :deep(.sky-flappy-bird__beak){fill:#f2a84b;stroke:#6f4727;stroke-width:1.5}.sky-bird--hero{top:72px;left:42px;width:100px;height:69px;animation:flappy-hero 1.1s ease-in-out infinite alternate}.sky-bird--hero :deep(.sky-flappy-bird__wing){animation:flappy-bird-glide 1.1s ease-in-out infinite}.sky-bird--hero :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-glide 1.1s ease-in-out infinite}.flappy-menu__copy>span{color:#ffda70;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-menu__copy h2{margin:3px 0 5px;font-size:21px}.flappy-menu__copy p{max-width:275px;margin:0;color:#c0c8df;font-size:10px;line-height:1.4}.flappy-record{width:100%;display:flex;align-items:center;justify-content:space-between;padding:9px 14px;border:1px solid #ffffff14;border-radius:13px;background:#ffffff0c}.flappy-record span{color:#bdc8df;font-size:9px;font-weight:800;text-transform:uppercase}.flappy-record strong{font-size:19px}.flappy-designs{width:100%;display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.flappy-designs button{display:grid;place-items:center;gap:3px;padding:7px 3px;border:1px solid #ffffff12;border-radius:12px;color:#ccd4e9;background:#ffffff0a;font-size:8px}.flappy-designs button.active{border-color:#ffdd72;color:#fff;background:#ffffff1b}.flappy-designs i{width:26px;height:13px;border-radius:8px}.flappy-designs__dawn{background:linear-gradient(90deg,#58d6ee,#ff9b80)}.flappy-designs__neon{background:linear-gradient(90deg,#24255f,#ef55ca)}.flappy-designs__storm{background:linear-gradient(90deg,#71899b,#253750)}.flappy-primary,.flappy-secondary{width:100%;min-height:43px;display:flex;align-items:center;justify-content:center;gap:7px;border-radius:14px;font-size:11px;font-weight:850}.flappy-primary{border:0;color:#173353;background:linear-gradient(135deg,#ffe16c,#ff9d68)}.flappy-secondary{border:1px solid #ffffff18;color:#fff;background:#ffffff0b}.flappy-menu>p,.flappy-game__hint{margin:0;color:#aeb9d2;font-size:9px}
.flappy-game{position:relative;height:calc(100% - 55px)}.flappy-toolbar{height:47px;display:grid;grid-template-columns:36px 1fr 1fr 36px;align-items:center;gap:7px}.flappy-toolbar div{display:grid;justify-items:center}.flappy-toolbar span{color:#bac9df;font-size:8px;font-weight:850;text-transform:uppercase}.flappy-toolbar strong{font-size:16px}.flappy-stage{position:relative;width:100%;height:500px;display:block;overflow:hidden;padding:0;border:1px solid #ffffff1c;border-radius:22px;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 35px #15244c55,0 16px 30px #10193477;touch-action:manipulation}.flappy-clouds{position:absolute;z-index:1;inset:0;overflow:hidden;pointer-events:none}.flappy-clouds i{--cloud-scale:1;--cloud-opacity:.34;--cloud-duration:18s;--cloud-delay:0s;position:absolute;left:100%;width:70px;height:18px;border-radius:999px;background:linear-gradient(180deg,#ffffffd9,#eaf7ff9c);box-shadow:0 8px 16px #24376518;opacity:var(--cloud-opacity);animation:cloud-drift var(--cloud-duration) linear var(--cloud-delay) infinite;will-change:transform}.flappy-clouds i::before{position:absolute;bottom:4px;left:12px;width:29px;height:29px;border-radius:50%;background:#f8fcffe6;box-shadow:22px -8px 0 4px #f7fcff,40px 1px 0 -2px #eef9ff;content:""}.flappy-clouds i::after{position:absolute;right:8px;bottom:-3px;left:8px;height:8px;border-radius:50%;background:#bcdff477;filter:blur(4px);content:""}.flappy-clouds i:nth-child(1){--cloud-scale:.7;--cloud-opacity:.3;--cloud-duration:20s;--cloud-delay:-4s;top:9%}.flappy-clouds i:nth-child(2){--cloud-scale:1.05;--cloud-opacity:.4;--cloud-duration:15s;--cloud-delay:-11s;top:22%}.flappy-clouds i:nth-child(3){--cloud-scale:.52;--cloud-opacity:.25;--cloud-duration:23s;--cloud-delay:-17s;top:38%}.flappy-clouds i:nth-child(4){--cloud-scale:.88;--cloud-opacity:.36;--cloud-duration:17s;--cloud-delay:-7s;top:53%}.flappy-clouds i:nth-child(5){--cloud-scale:1.18;--cloud-opacity:.42;--cloud-duration:14s;--cloud-delay:-2s;top:68%}.flappy-clouds i:nth-child(6){--cloud-scale:.62;--cloud-opacity:.27;--cloud-duration:21s;--cloud-delay:-14s;top:78%}.flappy-clouds i:nth-child(7){--cloud-scale:.96;--cloud-opacity:.34;--cloud-duration:16s;--cloud-delay:-9s;top:86%}.flappy-obstacle{position:absolute;z-index:2;top:0;bottom:0}.flappy-obstacle span{position:absolute;right:0;left:0;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 20%),var(--tower));box-shadow:inset -6px 0 8px #0003,0 0 13px #17204e55}.flappy-obstacle span::after{position:absolute;right:-4px;left:-4px;height:14px;border-radius:6px;background:color-mix(in srgb,var(--tower),white 10%);box-shadow:inset 0 3px 0 #ffffff25;content:""}.flappy-obstacle__top{top:0;border-radius:0 0 7px 7px}.flappy-obstacle__top::after{bottom:0}.flappy-obstacle__bottom{bottom:0;border-radius:7px 7px 0 0}.flappy-obstacle__bottom::after{top:0}.sky-bird--player{left:23%;animation:flappy-wing .24s ease-out}.sky-bird--player :deep(.sky-flappy-bird__wing){animation:flappy-bird-flap .24s cubic-bezier(.2,.75,.35,1)}.sky-bird--player :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-flap .24s cubic-bezier(.2,.75,.35,1)}.flappy-ready{position:absolute;z-index:6;top:36%;left:50%;padding:9px 15px;border-radius:17px;background:#15284fbb;font-size:11px;transform:translateX(-50%)}.flappy-horizon{position:absolute;z-index:3;right:0;bottom:0;left:0;height:12px;background:#263a62;box-shadow:0 -5px 14px #ffffff26}.flappy-stage--crashed{animation:flappy-crash .55s ease-out}.flappy-game__hint{margin-top:8px;text-align:center}.flappy-overlay{position:absolute;z-index:12;inset:47px 0 25px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:28px;border-radius:22px;background:#101a38dc;backdrop-filter:blur(7px);text-align:center}.flappy-overlay>svg{color:#ffdc71}.flappy-overlay>span{color:#ffad78;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-overlay h2{margin:0 0 5px;font-size:26px}
.flappy-game{position:absolute;inset:0}.flappy-toolbar{position:absolute;z-index:10;top:48px;right:14px;left:14px;height:42px;display:grid;grid-template-columns:36px 1fr 1fr 36px;align-items:center;gap:7px;padding:4px 6px;border:1px solid #ffffff2b;border-radius:22px;background:#263c6da8;box-shadow:0 8px 24px #10193455;backdrop-filter:blur(14px)}.flappy-toolbar div{display:grid;justify-items:center}.flappy-toolbar span{color:#bac9df;font-size:8px;font-weight:850;text-transform:uppercase}.flappy-toolbar strong{font-size:16px}.flappy-toolbar button{width:34px;height:34px;border:0;border-radius:50%;box-shadow:none}.flappy-stage{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:hidden;padding:0;border:0;border-radius:0;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 35px #15244c55;touch-action:manipulation}.flappy-clouds{position:absolute;z-index:1;inset:0;overflow:hidden;pointer-events:none}.flappy-clouds i{--cloud-scale:1;--cloud-opacity:.34;--cloud-duration:18s;--cloud-delay:0s;position:absolute;left:100%;width:70px;height:18px;border-radius:999px;background:linear-gradient(180deg,#ffffffd9,#eaf7ff9c);box-shadow:0 8px 16px #24376518;opacity:var(--cloud-opacity);animation:cloud-drift var(--cloud-duration) linear var(--cloud-delay) infinite;will-change:transform}.flappy-clouds i::before{position:absolute;bottom:4px;left:12px;width:29px;height:29px;border-radius:50%;background:#f8fcffe6;box-shadow:22px -8px 0 4px #f7fcff,40px 1px 0 -2px #eef9ff;content:""}.flappy-clouds i::after{position:absolute;right:8px;bottom:-3px;left:8px;height:8px;border-radius:50%;background:#bcdff477;filter:blur(4px);content:""}.flappy-clouds i:nth-child(1){--cloud-scale:.7;--cloud-opacity:.3;--cloud-duration:20s;--cloud-delay:-4s;top:9%}.flappy-clouds i:nth-child(2){--cloud-scale:1.05;--cloud-opacity:.4;--cloud-duration:15s;--cloud-delay:-11s;top:22%}.flappy-clouds i:nth-child(3){--cloud-scale:.52;--cloud-opacity:.25;--cloud-duration:23s;--cloud-delay:-17s;top:38%}.flappy-clouds i:nth-child(4){--cloud-scale:.88;--cloud-opacity:.36;--cloud-duration:17s;--cloud-delay:-7s;top:53%}.flappy-clouds i:nth-child(5){--cloud-scale:1.18;--cloud-opacity:.42;--cloud-duration:14s;--cloud-delay:-2s;top:68%}.flappy-clouds i:nth-child(6){--cloud-scale:.62;--cloud-opacity:.27;--cloud-duration:21s;--cloud-delay:-14s;top:78%}.flappy-clouds i:nth-child(7){--cloud-scale:.96;--cloud-opacity:.34;--cloud-duration:16s;--cloud-delay:-9s;top:86%}.flappy-obstacle{position:absolute;z-index:2;top:0;bottom:0}.flappy-obstacle span{position:absolute;right:0;left:0;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 20%),var(--tower));box-shadow:inset -6px 0 8px #0003,0 0 13px #17204e55}.flappy-obstacle span::after{position:absolute;right:-4px;left:-4px;height:14px;border-radius:6px;background:color-mix(in srgb,var(--tower),white 10%);box-shadow:inset 0 3px 0 #ffffff25;content:""}.flappy-obstacle__top{top:0;border-radius:0 0 7px 7px}.flappy-obstacle__top::after{bottom:0}.flappy-obstacle__bottom{bottom:0;border-radius:7px 7px 0 0}.flappy-obstacle__bottom::after{top:0}.sky-bird--player{left:23%;animation:flappy-wing .24s ease-out}.sky-bird--player :deep(.sky-flappy-bird__wing){animation:flappy-bird-flap .24s cubic-bezier(.2,.75,.35,1)}.sky-bird--player :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-flap .24s cubic-bezier(.2,.75,.35,1)}.flappy-ready{position:absolute;z-index:6;top:36%;left:50%;padding:9px 15px;border-radius:17px;background:#15284fbb;font-size:11px;transform:translateX(-50%)}.flappy-horizon{position:absolute;z-index:3;right:0;bottom:0;left:0;height:12px;background:#263a62;box-shadow:0 -5px 14px #ffffff26}.flappy-stage--crashed{animation:flappy-crash .55s ease-out}.flappy-game__hint{position:absolute;z-index:6;right:45px;bottom:27px;left:45px;margin:0;padding:7px 10px;border-radius:999px;background:#263c6d91;backdrop-filter:blur(10px);text-align:center;pointer-events:none}.flappy-overlay{position:absolute;z-index:12;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:28px;background:#101a38dc;backdrop-filter:blur(7px);text-align:center}.flappy-overlay>svg{color:#ffdc71}.flappy-overlay>span{color:#ffad78;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-overlay h2{margin:0 0 5px;font-size:26px}
.flappy-menu__tower{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff38,inset -6px 0 9px #0004,0 0 18px var(--tower-glow)}
.flappy-menu__tower::after{border-color:var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower));box-shadow:inset 0 4px 0 #ffffff45,0 0 14px var(--tower-glow)}
.flappy-menu__copy>span{color:#ffdf7d;font-size:14px}
@@ -178,9 +186,13 @@ onBeforeUnmount(() => {
.flappy-primary,.flappy-secondary{min-height:50px;gap:8px;font-size:18px}
.flappy-secondary{border-color:#ffffff2c;background:#ffffff12}
.flappy-menu>p,.flappy-game__hint{color:#e1e9f6;font-size:16px;font-weight:700;line-height:1.35}
.flappy-toolbar span{color:#e0eafa;font-size:12px;letter-spacing:.35px}
.flappy-toolbar strong{font-size:21px}
.flappy-stage{border:2px solid #ffffff70;box-shadow:inset 0 0 35px #15244c38,0 16px 30px #10193477}
.flappy-toolbar span{color:#e0eafa;font-size:11px;line-height:10px;letter-spacing:.35px}
.flappy-toolbar strong{display:block;font-size:19px;line-height:20px}
.flappy-toolbar{top:66px;right:18px;left:18px;height:42px;grid-template-columns:32px 1fr 1fr 32px;gap:4px;padding:4px;border-radius:21px;box-sizing:border-box}
.flappy-toolbar div{height:32px;display:grid;grid-template-rows:10px 20px;align-content:center;justify-items:center}
.flappy-toolbar button{width:32px;height:32px}
.flappy-clouds{top:110px}
.flappy-stage{border:0;box-shadow:inset 0 0 35px #15244c38}
.flappy-obstacle span{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff42,inset -6px 0 8px #0004,0 0 18px var(--tower-glow)}
.flappy-obstacle span::after{right:-5px;left:-5px;height:17px;border:2px solid var(--tower-dark);background:linear-gradient(180deg,var(--tower-light),var(--tower));box-shadow:inset 0 4px 0 #ffffff55,0 0 14px var(--tower-glow)}
.flappy-ready{padding:11px 18px;border:2px solid #ffffff55;border-radius:19px;background:#15284fe6;font-size:16px;font-weight:850}
+91 -106
View File
@@ -1,13 +1,10 @@
<script setup lang="ts">
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Pause,
Play,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import {
SNAKE_BOARD_HEIGHT,
@@ -23,17 +20,7 @@ import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
const snake = useSnakeStore()
const touchStart = ref<SnakePoint | null>(null)
const speedOptions: SnakeSpeed[] = ['relaxed', 'normal', 'fast']
const directionButtons: Array<{
direction: SnakeDirection
icon: typeof ChevronUp
}> = [
{ direction: 'up', icon: ChevronUp },
{ direction: 'left', icon: ChevronLeft },
{ direction: 'down', icon: ChevronDown },
{ direction: 'right', icon: ChevronRight },
]
const game = computed(() => snake.game)
const boardMotionStyle = computed(() => ({
'--snake-motion-duration': `${Math.min(
@@ -90,7 +77,9 @@ function stopGameTimer(): void {
function syncGameTimer(): void {
stopGameTimer()
if (snake.game?.status === 'playing') {
gameTimer = setInterval(() => snake.tick(), snake.tickMilliseconds)
gameTimer = setInterval(() => {
snake.tick()
}, snake.tickMilliseconds)
}
}
@@ -99,16 +88,16 @@ function returnToMenu(): void {
snake.showMenu()
}
function startGame(): void {
snake.start()
}
function handleKeydown(event: KeyboardEvent): void {
const directionByKey: Partial<Record<string, SnakeDirection>> = {
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
a: 'left',
d: 'right',
s: 'down',
w: 'up',
}
const direction = directionByKey[event.key]
@@ -125,28 +114,6 @@ function handleKeydown(event: KeyboardEvent): void {
}
}
function beginSwipe(event: TouchEvent): void {
const touch = event.changedTouches[0]
touchStart.value = touch ? { x: touch.clientX, y: touch.clientY } : null
}
function endSwipe(event: TouchEvent): void {
const start = touchStart.value
const touch = event.changedTouches[0]
touchStart.value = null
if (!start || !touch) return
const deltaX = touch.clientX - start.x
const deltaY = touch.clientY - start.y
if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 18) return
if (Math.abs(deltaX) > Math.abs(deltaY)) {
snake.turn(deltaX > 0 ? 'right' : 'left')
} else {
snake.turn(deltaY > 0 ? 'down' : 'up')
}
}
snake.hydrate()
watch(
() => [snake.game?.status, snake.tickMilliseconds],
@@ -162,8 +129,12 @@ onBeforeUnmount(() => {
</script>
<template>
<main class="snake-app" :aria-label="phone.t('Apps.snake.name')">
<header class="snake-header">
<main
class="snake-app"
:class="{ 'snake-app--playing': game }"
:aria-label="phone.t('Apps.snake.name')"
>
<header v-if="!game" class="snake-header">
<span class="snake-brand">{{ phone.t('Apps.snake.name') }}</span>
<div class="snake-score-card">
<span>{{ phone.t('Apps.snake.highScore') }}</span>
@@ -226,7 +197,7 @@ onBeforeUnmount(() => {
{{ phone.t(`Apps.snake.speeds.${speed}`) }}
</button>
</fieldset>
<button type="button" class="snake-primary" @click="snake.start">
<button type="button" class="snake-primary" @click="startGame">
<Play :size="18" fill="currentColor" />
{{ phone.t('Apps.snake.start') }}
</button>
@@ -243,8 +214,10 @@ onBeforeUnmount(() => {
>
<ChevronLeft :size="18" :stroke-width="2.7" aria-hidden="true" />
</button>
<span>{{ phone.t('Apps.snake.score') }}</span>
<strong>{{ game.score }}</strong>
<div>
<span>{{ phone.t('Apps.snake.score') }}</span>
<strong>{{ game.score }}</strong>
</div>
<button
v-if="game.status !== 'game-over'"
type="button"
@@ -267,8 +240,6 @@ onBeforeUnmount(() => {
class="snake-board"
:style="boardMotionStyle"
:aria-label="phone.t('Apps.snake.board')"
@touchstart.passive="beginSwipe"
@touchend.passive="endSwipe"
>
<span
v-for="(_, index) in game.body.slice(1)"
@@ -306,7 +277,7 @@ onBeforeUnmount(() => {
<template v-else>
<span class="snake-overline">{{ phone.t('Apps.snake.score') }} {{ game.score }}</span>
<h2>{{ phone.t('Apps.snake.gameOver') }}</h2>
<button type="button" class="snake-primary" @click="snake.start">
<button type="button" class="snake-primary" @click="startGame">
{{ phone.t('Apps.snake.restart') }}
</button>
<button type="button" class="snake-secondary" @click="returnToMenu">
@@ -317,18 +288,6 @@ onBeforeUnmount(() => {
</div>
<p class="snake-hint">{{ phone.t('Apps.snake.swipeHint') }}</p>
<div class="snake-controls" :aria-label="phone.t('Apps.snake.controls')">
<button
v-for="control in directionButtons"
:key="control.direction"
type="button"
:class="`snake-control--${control.direction}`"
:aria-label="phone.t(`Apps.snake.directions.${control.direction}`)"
@click="snake.turn(control.direction)"
>
<component :is="control.icon" :size="24" :stroke-width="2.6" />
</button>
</div>
</section>
</main>
</template>
@@ -348,6 +307,10 @@ onBeforeUnmount(() => {
touch-action: none;
}
.snake-app--playing {
padding: 0;
}
.snake-header,
.snake-game__meta {
display: flex;
@@ -507,53 +470,89 @@ onBeforeUnmount(() => {
}
.snake-game {
padding-top: 8px;
position: absolute;
inset: 0;
}
.snake-game__meta {
height: 43px;
justify-content: flex-start;
gap: 8px;
position: absolute;
z-index: 7;
top: 66px;
right: 18px;
left: 18px;
height: 42px;
display: grid;
grid-template-columns: 32px 1fr 32px;
align-items: center;
gap: 4px;
padding: 4px;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 21px;
background: rgb(8 22 18 / 58%);
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
backdrop-filter: blur(14px);
box-sizing: border-box;
}
.snake-game__meta div {
height: 32px;
display: grid;
grid-template-rows: 10px 20px;
align-content: center;
justify-items: center;
}
.snake-game__meta span {
color: #9fb3a9;
font-size: 11px;
font-weight: 700;
line-height: 10px;
text-transform: uppercase;
}
.snake-game__meta strong {
font-size: 22px;
display: block;
font-size: 19px;
line-height: 20px;
}
.snake-game__meta button {
width: 34px;
height: 34px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid rgb(255 255 255 / 9%);
border: 0;
border-radius: 50%;
color: #dff6d9;
background: rgb(255 255 255 / 7%);
background: rgb(255 255 255 / 8%);
}
.snake-game__meta .snake-game__pause { margin-left: auto; }
.snake-game__meta .snake-game__back { flex: 0 0 auto; }
.snake-game__meta .snake-game__pause { justify-self: end; }
.snake-game__meta .snake-game__back { justify-self: start; }
.snake-board {
position: relative;
width: 100%;
aspect-ratio: 16 / 18;
position: absolute;
top: 118px;
right: 18px;
bottom: 62px;
left: 18px;
overflow: hidden;
border: 1px solid rgb(167 231 157 / 16%);
border: 1px solid rgb(145 228 117 / 24%);
border-radius: 18px;
background-color: #162d26;
background-image:
linear-gradient(rgb(255 255 255 / 2%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 2%) 1px, transparent 1px);
background-size: calc(100% / 16) calc(100% / 18);
box-shadow: inset 0 0 35px rgb(0 0 0 / 20%), 0 17px 35px rgb(0 0 0 / 20%);
linear-gradient(rgb(173 233 160 / 4%) 1px, transparent 1px),
linear-gradient(90deg, rgb(173 233 160 / 4%) 1px, transparent 1px),
radial-gradient(circle at 50% 42%, rgb(94 191 91 / 8%), transparent 68%);
background-size:
calc(100% / 16) calc(100% / 30),
calc(100% / 16) calc(100% / 30),
100% 100%;
box-shadow:
inset 0 0 55px rgb(0 0 0 / 24%),
inset 0 0 0 1px rgb(213 255 199 / 3%),
0 12px 30px rgb(0 0 0 / 20%),
0 0 18px rgb(89 196 82 / 7%);
}
.snake-head,
@@ -679,36 +678,22 @@ onBeforeUnmount(() => {
}
.snake-hint {
margin: 9px 0 6px;
color: #71867c;
position: absolute;
z-index: 6;
right: 50px;
bottom: 27px;
left: 50px;
margin: 0;
padding: 7px 10px;
border-radius: 999px;
color: #9eb2a8;
background: rgb(8 22 18 / 48%);
backdrop-filter: blur(10px);
font-size: 10px;
text-align: center;
pointer-events: none;
}
.snake-controls {
position: relative;
width: 132px;
height: 91px;
margin: 0 auto;
}
.snake-controls button {
position: absolute;
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 13px;
color: #cae6d1;
background: rgb(255 255 255 / 7%);
}
.snake-control--up { left: 45px; top: 0; }
.snake-control--left { left: 0; top: 45px; }
.snake-control--down { left: 45px; top: 45px; }
.snake-control--right { right: 0; top: 45px; }
button:active {
transform: scale(0.96);
}
+41 -29
View File
@@ -27,7 +27,7 @@ import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
const tower = useTowerStackStore()
const game = computed(() => tower.game)
const visibleBlocks = computed(() => game.value?.blocks.slice(-9) ?? [])
const visibleBlocks = computed(() => game.value?.blocks.slice(-6) ?? [])
const placementEffect = ref<'missed' | 'perfect' | 'placed' | null>(null)
const fallingBlock = ref<TowerActiveBlock | null>(null)
const fallingStyle = ref<CSSProperties>({})
@@ -175,8 +175,12 @@ onBeforeUnmount(() => {
</script>
<template>
<main class="tower-app" :aria-label="phone.t('Apps.towerStack.name')">
<header class="tower-header">
<main
class="tower-app"
:class="{ 'tower-app--playing': !tower.menuOpen && game }"
:aria-label="phone.t('Apps.towerStack.name')"
>
<header v-if="tower.menuOpen" class="tower-header">
<div>
<span>{{ phone.t('Apps.towerStack.eyebrow') }}</span>
<h1>{{ phone.t('Apps.towerStack.name') }}</h1>
@@ -214,7 +218,13 @@ onBeforeUnmount(() => {
<Play :size="17" fill="currentColor" />
{{ phone.t('Apps.towerStack.resume') }}
</button>
<button type="button" class="tower-secondary" @click="startGame">
<button
type="button"
:class="game?.status === 'paused' ? 'tower-secondary' : 'tower-primary'"
@click="startGame"
>
<RotateCcw v-if="game" :size="16" aria-hidden="true" />
<Play v-else :size="16" fill="currentColor" aria-hidden="true" />
{{ phone.t(game ? 'Apps.towerStack.newGame' : 'Apps.towerStack.start') }}
</button>
<p class="tower-menu__hint">{{ phone.t('Apps.towerStack.tapHint') }}</p>
@@ -305,32 +315,34 @@ onBeforeUnmount(() => {
<style scoped>
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; touch-action: manipulation; }
.tower-header { height: 55px; display: flex; align-items: center; justify-content: space-between; }
.tower-header span { display: block; color: #c1b8f1; font-size: 14px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
.tower-header h1 { margin: 1px 0 0; font-size: 32px; line-height: 1; letter-spacing: -0.8px; }
.tower-app--playing { padding: 0; }
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; }
.tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
.tower-header h1 { margin: 1px 0 0; font-size: 27px; line-height: 1; letter-spacing: -0.8px; }
.tower-header button, .tower-toolbar button { width: 36px; height: 36px; display: grid; place-items: center; padding: 0; border: 1px solid #ffffff14; border-radius: 12px; color: #f2edff; background: #ffffff0d; }
.tower-menu { height: calc(100% - 55px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; text-align: center; }
.tower-menu__preview { position: relative; width: 190px; height: 210px; }
.tower-menu__preview i { position: absolute; right: 12px; bottom: calc(var(--preview-index) * 25px); left: 25px; height: 29px; border-radius: 7px; background: hsl(calc(var(--preview-index) * 49deg + 5deg) 82% 62%); box-shadow: inset 0 4px 0 #ffffff35, 0 8px 15px #08091c5c; transform: perspective(200px) rotateX(5deg); }
.tower-menu__preview i:nth-child(even) { right: 25px; left: 12px; }
.tower-menu__preview span { position: absolute; top: 4px; left: 2px; width: 120px; height: 29px; border-radius: 7px; background: #ff6b68; box-shadow: 0 0 24px #ff6b6877; animation: tower-preview-slide 1.5s ease-in-out infinite alternate; }
.tower-menu__copy > span { color: #ffcf59; font-size: 14px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase; }
.tower-menu__copy h2 { margin: 4px 0 7px; font-size: 30px; }
.tower-menu__copy p { max-width: 295px; margin: 0; color: #e0dcf2; font-size: 18px; font-weight: 550; line-height: 1.4; }
.tower-menu { height: calc(100% - 50px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; text-align: center; }
.tower-menu__preview { position: relative; flex: 0 0 124px; width: 158px; height: 124px; }
.tower-menu__preview i { position: absolute; right: 12px; bottom: calc((var(--preview-index) - 1) * 15px); left: 23px; height: 21px; border-radius: 6px; background: hsl(calc(var(--preview-index) * 49deg + 5deg) 82% 62%); box-shadow: inset 0 3px 0 #ffffff35, 0 5px 11px #08091c5c; transform: perspective(200px) rotateX(5deg); }
.tower-menu__preview i:nth-child(even) { right: 23px; left: 12px; }
.tower-menu__preview span { position: absolute; top: 0; left: 4px; width: 100px; height: 21px; border-radius: 6px; background: #ff6b68; box-shadow: 0 0 20px #ff6b6877; animation: tower-preview-slide 1.5s ease-in-out infinite alternate; }
.tower-menu__copy > span { color: #ffcf59; font-size: 10px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase; }
.tower-menu__copy h2 { max-width: 270px; margin: 3px auto 5px; font-size: 24px; line-height: 1.08; }
.tower-menu__copy p { max-width: 285px; margin: 0; color: #d8d3eb; font-size: 12px; font-weight: 550; line-height: 1.35; }
.tower-records { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.tower-records div { display: grid; gap: 3px; padding: 12px 9px; border: 1px solid #ffffff1f; border-radius: 13px; background: #ffffff0d; }
.tower-records span { color: #d1caea; font-size: 15px; font-weight: 850; text-transform: uppercase; }
.tower-records strong { font-size: 26px; }
.tower-primary, .tower-secondary { width: 100%; min-height: 50px; display: flex; align-items: center; justify-content: center; gap: 8px; border-radius: 14px; font-size: 18px; font-weight: 850; }
.tower-records div { display: grid; gap: 1px; padding: 8px 9px; border: 1px solid #ffffff1f; border-radius: 13px; background: #ffffff0d; backdrop-filter: blur(8px); }
.tower-records span { color: #d1caea; font-size: 9px; font-weight: 850; letter-spacing: .35px; text-transform: uppercase; }
.tower-records strong { font-size: 20px; }
.tower-primary, .tower-secondary { width: 100%; min-height: 42px; display: flex; align-items: center; justify-content: center; gap: 7px; border-radius: 14px; font-size: 13px; font-weight: 850; }
.tower-primary { border: 0; color: #1e1839; background: linear-gradient(135deg, #ffca4f, #ff8760); box-shadow: 0 8px 18px #ff895330; }
.tower-secondary { border: 1px solid #ffffff16; color: #eeeaff; background: #ffffff0a; }
.tower-menu__hint, .tower-game__hint { margin: 0; color: #e0daf0; font-size: 16px; font-weight: 700; line-height: 1.35; }
.tower-game { position: relative; height: calc(100% - 55px); }
.tower-toolbar { height: 47px; display: grid; grid-template-columns: 36px 1fr 1fr 36px; align-items: center; gap: 7px; }
.tower-toolbar div { display: grid; justify-items: center; line-height: 1.05; }
.tower-toolbar span { color: #c9c2e9; font-size: 12px; font-weight: 850; letter-spacing: .35px; text-transform: uppercase; }
.tower-toolbar strong { font-size: 21px; }
.tower-stage { position: relative; width: 100%; height: 500px; display: block; overflow: hidden; padding: 0; border: 1px solid #ffffff35; border-radius: 22px; background: linear-gradient(#1d1b52, #433078 60%, #8e4c78); box-shadow: inset 0 0 35px #08091d80, 0 16px 28px #08091c66; touch-action: manipulation; }
.tower-menu__hint, .tower-game__hint { margin: 0; color: #c9c2e0; font-size: 10px; font-weight: 700; line-height: 1.35; }
.tower-game { position: absolute; inset: 0; }
.tower-toolbar { position: absolute; z-index: 10; top: 66px; right: 18px; left: 18px; height: 42px; display: grid; grid-template-columns: 32px 1fr 1fr 32px; align-items: center; gap: 4px; padding: 4px; border: 1px solid #ffffff1a; border-radius: 21px; background: #292451a8; box-shadow: 0 8px 24px #08091c52; backdrop-filter: blur(14px); box-sizing: border-box; }
.tower-toolbar div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
.tower-toolbar span { color: #c9c2e9; font-size: 11px; font-weight: 850; line-height: 10px; letter-spacing: .35px; text-transform: uppercase; }
.tower-toolbar strong { display: block; font-size: 19px; line-height: 20px; }
.tower-toolbar button { width: 32px; height: 32px; border: 0; border-radius: 50%; box-shadow: none; }
.tower-stage { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: hidden; padding: 0; border: 0; border-radius: 0; background: linear-gradient(#1d1b52, #433078 60%, #8e4c78); box-shadow: inset 0 0 35px #08091d80; touch-action: manipulation; }
.tower-sky { position: absolute; inset: 0; pointer-events: none; }
.tower-sky i { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: #fff; box-shadow: 0 0 7px #c5c2ff; opacity: .65; }
.tower-sky i:nth-child(1) { top: 8%; left: 12%; } .tower-sky i:nth-child(2) { top: 17%; left: 72%; } .tower-sky i:nth-child(3) { top: 28%; left: 42%; } .tower-sky i:nth-child(4) { top: 37%; left: 88%; } .tower-sky i:nth-child(5) { top: 46%; left: 18%; } .tower-sky i:nth-child(6) { top: 58%; left: 64%; } .tower-sky i:nth-child(7) { top: 70%; left: 31%; } .tower-sky i:nth-child(8) { top: 11%; left: 91%; } .tower-sky i:nth-child(9) { top: 22%; left: 25%; } .tower-sky i:nth-child(10) { top: 50%; left: 78%; } .tower-sky i:nth-child(11) { top: 64%; left: 8%; } .tower-sky i:nth-child(12) { top: 77%; left: 92%; } .tower-sky i:nth-child(13) { top: 33%; left: 58%; }
@@ -343,13 +355,13 @@ onBeforeUnmount(() => {
.tower-perfect { position: absolute; z-index: 8; top: 24%; left: 50%; padding: 8px 17px; border-radius: 18px; color: #332149; background: #ffdc65; box-shadow: 0 0 24px #ffcf64aa; font-size: 13px; font-weight: 950; letter-spacing: .8px; transform: translateX(-50%); animation: tower-perfect-pop 650ms ease-out forwards; }
.tower-stage--perfect { animation: tower-perfect-glow 500ms ease-out; }
.tower-stage--missed { animation: tower-stage-shake 500ms ease-out; }
.tower-game__hint { margin-top: 8px; text-align: center; }
.tower-overlay { position: absolute; z-index: 15; inset: 47px 0 25px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 28px; border-radius: 22px; color: #f7f2ff; background: #11122bd9; backdrop-filter: blur(7px); text-align: center; }
.tower-game__hint { position: absolute; z-index: 6; right: 45px; bottom: 27px; left: 45px; margin: 0; padding: 7px 10px; border-radius: 999px; color: #e0daf0; background: #29245191; backdrop-filter: blur(10px); text-align: center; pointer-events: none; }
.tower-overlay { position: absolute; z-index: 15; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 28px; color: #f7f2ff; background: #11122bd9; backdrop-filter: blur(7px); text-align: center; }
.tower-overlay > svg { color: #ffbd4d; }
.tower-overlay > span { color: #ffad78; font-size: 12px; font-weight: 900; letter-spacing: 1.2px; text-transform: uppercase; }
.tower-overlay h2 { margin: 0; font-size: 25px; }
.tower-overlay p { margin: -3px 0 5px; color: #c7c1e4; font-size: 14px; }
@keyframes tower-preview-slide { from { transform: translateX(0) rotate(-2deg); } to { transform: translateX(66px) rotate(2deg); } }
@keyframes tower-preview-slide { from { transform: translateX(0) rotate(-2deg); } to { transform: translateX(48px) rotate(2deg); } }
@keyframes tower-land { from { filter: brightness(1.8); transform: translateY(-8px) scaleY(.85); } to { filter: brightness(1); transform: translateY(0) scaleY(1); } }
@keyframes tower-active-pulse { from { filter: saturate(1.2) brightness(1.08); } to { filter: saturate(1.45) brightness(1.35); } }
@keyframes tower-fall { 0% { opacity: 1; transform: translate(0) rotate(0); } 100% { opacity: 0; transform: translate(45px, 390px) rotate(145deg); } }