Spaces:
Running
Running
| <script setup lang="ts"> | |
| import { ref, onMounted, computed, onBeforeUnmount } from 'vue' | |
| import { useWishStore } from '@/stores/wish' | |
| import { useAuthStore } from '@/stores/auth' | |
| import WishPanel from '@/components/WishPanel.vue' | |
| import UndoBanner from '@/components/UndoBanner.vue' | |
| const mobilePanelOpen = ref(false) | |
| const filterOpen = ref(false) | |
| const isMobileViewport = ref(false) | |
| const MOBILE_TREE_LIMIT = 9 | |
| const DESKTOP_TREE_LIMIT = 30 | |
| const CARD_COLORS = ['card-rose', 'card-amber', 'card-mint', 'card-sky', 'card-violet', 'card-peach'] as const | |
| const STATUS_ICONS: Record<string, string> = { | |
| active: '\u{1F331}', | |
| expired: '\u{1F31F}', | |
| fulfilled: '\u{2728}', | |
| } | |
| const STATUS_LABELS: Record<string, string> = { | |
| active: '\u{1F331} 愿望实现中', | |
| expired: '\u{1F31F} 已过期', | |
| fulfilled: '\u{2728} 已实现', | |
| } | |
| const FILTER_OPTIONS = [ | |
| { value: 'all', label: '全部' }, | |
| { value: 'active', label: '未实现' }, | |
| { value: 'fulfilled', label: '已实现' }, | |
| { value: 'expired', label: '已过期' }, | |
| ] as const | |
| const wishStore = useWishStore() | |
| const authStore = useAuthStore() | |
| // Popover state | |
| const hoveredWishId = ref<number | null>(null) | |
| const popoverPos = ref({ x: 0, y: 0 }) | |
| let hoverTimer: ReturnType<typeof setTimeout> | null = null | |
| const popoverWish = computed(() => { | |
| if (!hoveredWishId.value) return null | |
| return wishStore.treeWishes.find(w => w.id === hoveredWishId.value) ?? null | |
| }) | |
| const isOwner = computed(() => { | |
| if (!popoverWish.value || !authStore.user) return false | |
| return popoverWish.value.user_id === authStore.user.id | |
| }) | |
| const canFulfill = computed(() => { | |
| return isOwner.value && popoverWish.value?.is_fulfilled !== 1 | |
| }) | |
| function formatDateTime(ts: number) { | |
| const d = new Date(ts * 1000) | |
| const pad = (n: number) => String(n).padStart(2, '0') | |
| return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} (UTC+8)` | |
| } | |
| function onCardEnter(wish: any, event: MouseEvent) { | |
| if (isMobileViewport.value) return | |
| if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null } | |
| const target = event.currentTarget as HTMLElement | |
| const rect = target.getBoundingClientRect() | |
| popoverPos.value = { | |
| x: rect.left + rect.width / 2, | |
| y: rect.bottom + 8, | |
| } | |
| hoveredWishId.value = wish.id | |
| } | |
| function onCardLeave() { | |
| if (isMobileViewport.value) return | |
| if (hoverTimer) clearTimeout(hoverTimer) | |
| hoverTimer = setTimeout(() => { hoveredWishId.value = null }, 200) | |
| } | |
| function onPopoverEnter() { | |
| if (isMobileViewport.value) return | |
| if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null } | |
| } | |
| function onPopoverLeave() { | |
| if (isMobileViewport.value) return | |
| if (hoverTimer) clearTimeout(hoverTimer) | |
| hoverTimer = setTimeout(() => { hoveredWishId.value = null }, 200) | |
| } | |
| function updateViewportMode() { | |
| const nextIsMobile = window.innerWidth <= 768 | |
| const nextLimit = nextIsMobile ? MOBILE_TREE_LIMIT : DESKTOP_TREE_LIMIT | |
| const limitChanged = wishStore.treeLimit !== nextLimit | |
| isMobileViewport.value = nextIsMobile | |
| wishStore.setTreeLimit(nextLimit) | |
| if (limitChanged && wishStore.treeWishes.length > 0) { | |
| wishStore.fetchTreeWishes(1, wishStore.treeFilter) | |
| } | |
| } | |
| function openWishDetail(wish: any, event: MouseEvent) { | |
| if (!isMobileViewport.value) { | |
| onCardEnter(wish, event) | |
| return | |
| } | |
| if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null } | |
| hoveredWishId.value = wish.id | |
| } | |
| function closeWishDetail() { | |
| hoveredWishId.value = null | |
| } | |
| async function fulfillWish() { | |
| if (!popoverWish.value) return | |
| await wishStore.fulfillWish(popoverWish.value.id) | |
| await wishStore.fetchTreeWishes() | |
| await wishStore.fetchMyWishes() | |
| } | |
| async function deleteWish() { | |
| if (!popoverWish.value) return | |
| await wishStore.deleteWish(popoverWish.value.id) | |
| hoveredWishId.value = null | |
| await wishStore.fetchTreeWishes() | |
| await wishStore.fetchMyWishes() | |
| await wishStore.fetchDeletedWishes() | |
| } | |
| const currentFilterLabel = computed(() => { | |
| const opt = FILTER_OPTIONS.find(o => o.value === wishStore.treeFilter) | |
| return opt ? opt.label : '全部' | |
| }) | |
| async function handleUndo(wishId: number) { | |
| try { | |
| await wishStore.restoreWish(wishId) | |
| await wishStore.fetchTreeWishes() | |
| await wishStore.fetchMyWishes() | |
| await wishStore.fetchDeletedWishes() | |
| } catch { | |
| // restore failed — banner stays | |
| } | |
| } | |
| async function refreshDeletedState() { | |
| if (authStore.isAuthenticated) { | |
| await wishStore.fetchDeletedWishes() | |
| } | |
| } | |
| function setFilter(value: string) { | |
| filterOpen.value = false | |
| wishStore.fetchTreeWishes(1, value) | |
| } | |
| function closeFilter(e: MouseEvent) { | |
| if (filterOpen.value && !(e.target as HTMLElement).closest('.filter-dropdown')) { | |
| filterOpen.value = false | |
| } | |
| } | |
| function prevPage() { | |
| if (wishStore.treePage > 1) wishStore.fetchTreeWishes(wishStore.treePage - 1, wishStore.treeFilter) | |
| } | |
| function nextPage() { | |
| if (wishStore.treePage < wishStore.treeTotalPages) wishStore.fetchTreeWishes(wishStore.treePage + 1, wishStore.treeFilter) | |
| } | |
| onMounted(async () => { | |
| updateViewportMode() | |
| wishStore.fetchTreeWishes() | |
| await refreshDeletedState() | |
| document.addEventListener('click', closeFilter) | |
| window.addEventListener('resize', updateViewportMode) | |
| }) | |
| onBeforeUnmount(() => { | |
| document.removeEventListener('click', closeFilter) | |
| window.removeEventListener('resize', updateViewportMode) | |
| if (hoverTimer) clearTimeout(hoverTimer) | |
| }) | |
| </script> | |
| <template> | |
| <div class="min-h-screen bg-page-bg"> | |
| <!-- Hero --> | |
| <section class="px-6 pt-7 pb-4 md:px-10"> | |
| <a | |
| href="https://jerry20062016-clouduse.hf.space/" | |
| class="inline-flex items-center px-3.5 py-2 rounded-full bg-gold/12 text-gold-deep text-xs font-bold tracking-widest uppercase transition-colors hover:bg-gold/18 focus:outline-none focus:ring-2 focus:ring-gold/30" | |
| > | |
| Wish Tree | |
| </a> | |
| <h1 class="mt-3.5 mb-2.5 text-[clamp(2.3rem,4vw,4.4rem)] leading-[0.96] tracking-tight font-semibold text-text-main"> | |
| 把愿望挂上树,让它在时间里慢慢发芽。 | |
| </h1> | |
| <p class="max-w-[760px] text-text-soft leading-[1.72]"> | |
| 在这里许下你的心愿。每一个愿望都是一颗种子,有人为你浇灌,有人为你守护。愿所有美好的期许,都能在时光中生根。 | |
| </p> | |
| </section> | |
| <!-- Two-column layout --> | |
| <div class="flex gap-4 px-6 md:px-10"> | |
| <!-- Sidebar panel (desktop) --> | |
| <div class="hidden lg:block w-[340px] flex-shrink-0"> | |
| <div class="sticky top-4 rounded-xl bg-white/96 border border-gold/18"> | |
| <WishPanel /> | |
| </div> | |
| </div> | |
| <!-- Mobile panel toggle --> | |
| <button | |
| type="button" | |
| class="lg:hidden fixed bottom-4 right-4 z-50 w-12 h-12 rounded-full bg-gold text-white shadow-lg flex items-center justify-center text-xl" | |
| @click="mobilePanelOpen = !mobilePanelOpen" | |
| > | |
| {{ mobilePanelOpen ? '\u2715' : '\u{1F331}' }} | |
| </button> | |
| <!-- Mobile panel overlay --> | |
| <Transition name="slide"> | |
| <div | |
| v-if="mobilePanelOpen" | |
| class="lg:hidden fixed inset-0 z-40 bg-black/30" | |
| @click.self="mobilePanelOpen = false" | |
| > | |
| <div class="absolute right-0 top-0 bottom-0 w-[340px] max-w-[90vw] bg-page-bg overflow-y-auto shadow-2xl"> | |
| <div class="p-4"> | |
| <button | |
| type="button" | |
| class="ml-auto block text-text-muted hover:text-text-soft text-lg" | |
| @click="mobilePanelOpen = false" | |
| > | |
| Close | |
| </button> | |
| </div> | |
| <WishPanel /> | |
| </div> | |
| </div> | |
| </Transition> | |
| <!-- Tree area --> | |
| <div class="relative flex-1 min-w-0"> | |
| <!-- Undo Banner --> | |
| <UndoBanner v-if="wishStore.deletedWishes.length > 0" @undo="handleUndo" /> | |
| <!-- Filter --> | |
| <div class="mb-3 relative filter-dropdown"> | |
| <button | |
| type="button" | |
| class="inline-flex items-center gap-2 bg-panel-bg rounded-lg px-4 py-2 border border-gold/10 text-sm hover:bg-white/60 transition-colors" | |
| @click.stop="filterOpen = !filterOpen" | |
| > | |
| <span class="text-text-soft font-medium">{{ currentFilterLabel }}</span> | |
| <svg class="w-4 h-4 text-text-muted transition-transform" :class="{ 'rotate-180': filterOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> | |
| </svg> | |
| </button> | |
| <Transition name="fade"> | |
| <div | |
| v-if="filterOpen" | |
| class="absolute top-full left-0 mt-1 w-36 bg-white rounded-lg border border-gold/10 shadow-lg z-20 py-1 overflow-hidden" | |
| > | |
| <button | |
| v-for="opt in FILTER_OPTIONS" | |
| :key="opt.value" | |
| type="button" | |
| class="w-full text-left px-3.5 py-2 text-sm hover:bg-gold/8 transition-colors" | |
| :class="wishStore.treeFilter === opt.value ? 'text-gold font-semibold bg-gold/6' : 'text-text-main'" | |
| @click="setFilter(opt.value)" | |
| > | |
| {{ opt.label }} | |
| </button> | |
| </div> | |
| </Transition> | |
| </div> | |
| <!-- Tree Wallpaper Background --> | |
| <div class="tree-wallpaper"> | |
| <svg class="tree-wallpaper-svg" viewBox="0 0 1200 800" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg"> | |
| <!-- Tree 1 - far left --> | |
| <g class="mini-tree" transform="translate(80, 520) scale(0.6)"> | |
| <polygon points="50,0 100,70 0,70" fill="currentColor" opacity="0.5" /> | |
| <polygon points="50,30 110,110 -10,110" fill="currentColor" opacity="0.45" /> | |
| <polygon points="50,70 120,150 -20,150" fill="currentColor" opacity="0.4" /> | |
| <rect x="43" y="150" width="14" height="40" rx="3" fill="currentColor" opacity="0.3" /> | |
| </g> | |
| <!-- Tree 2 - left-center --> | |
| <g class="mini-tree" transform="translate(260, 440) scale(0.8)"> | |
| <polygon points="50,0 95,65 5,65" fill="currentColor" opacity="0.4" /> | |
| <polygon points="50,25 105,100 -5,100" fill="currentColor" opacity="0.35" /> | |
| <polygon points="50,60 115,140 -15,140" fill="currentColor" opacity="0.3" /> | |
| <rect x="44" y="140" width="12" height="35" rx="3" fill="currentColor" opacity="0.25" /> | |
| </g> | |
| <!-- Tree 3 - center-left --> | |
| <g class="mini-tree" transform="translate(480, 380) scale(1.0)"> | |
| <polygon points="50,0 90,60 10,60" fill="currentColor" opacity="0.35" /> | |
| <polygon points="50,20 100,90 0,90" fill="currentColor" opacity="0.3" /> | |
| <polygon points="50,50 110,130 -10,130" fill="currentColor" opacity="0.25" /> | |
| <rect x="44" y="130" width="12" height="35" rx="3" fill="currentColor" opacity="0.2" /> | |
| </g> | |
| <!-- Tree 4 - center (tallest) --> | |
| <g class="mini-tree" transform="translate(680, 300) scale(1.1)"> | |
| <polygon points="50,0 85,55 15,55" fill="currentColor" opacity="0.3" /> | |
| <polygon points="50,18 95,82 5,82" fill="currentColor" opacity="0.25" /> | |
| <polygon points="50,45 105,120 -5,120" fill="currentColor" opacity="0.2" /> | |
| <rect x="44" y="120" width="12" height="32" rx="3" fill="currentColor" opacity="0.18" /> | |
| </g> | |
| <!-- Tree 5 - right-center --> | |
| <g class="mini-tree" transform="translate(900, 420) scale(0.75)"> | |
| <polygon points="50,0 92,62 8,62" fill="currentColor" opacity="0.38" /> | |
| <polygon points="50,22 102,95 -2,95" fill="currentColor" opacity="0.32" /> | |
| <polygon points="50,55 112,135 -12,135" fill="currentColor" opacity="0.27" /> | |
| <rect x="43" y="135" width="14" height="36" rx="3" fill="currentColor" opacity="0.22" /> | |
| </g> | |
| <!-- Tree 6 - far right --> | |
| <g class="mini-tree" transform="translate(1080, 500) scale(0.55)"> | |
| <polygon points="50,0 96,68 4,68" fill="currentColor" opacity="0.45" /> | |
| <polygon points="50,28 108,108 -8,108" fill="currentColor" opacity="0.4" /> | |
| <polygon points="50,65 118,148 -18,148" fill="currentColor" opacity="0.35" /> | |
| <rect x="43" y="148" width="14" height="38" rx="3" fill="currentColor" opacity="0.28" /> | |
| </g> | |
| <!-- Small accent trees --> | |
| <g class="mini-tree" transform="translate(170, 600) scale(0.4)"> | |
| <polygon points="50,0 90,60 10,60" fill="currentColor" opacity="0.35" /> | |
| <polygon points="50,25 100,90 0,90" fill="currentColor" opacity="0.3" /> | |
| <rect x="44" y="90" width="12" height="28" rx="3" fill="currentColor" opacity="0.2" /> | |
| </g> | |
| <g class="mini-tree" transform="translate(780, 580) scale(0.35)"> | |
| <polygon points="50,0 88,58 12,58" fill="currentColor" opacity="0.32" /> | |
| <polygon points="50,22 98,88 2,88" fill="currentColor" opacity="0.27" /> | |
| <rect x="44" y="88" width="12" height="25" rx="3" fill="currentColor" opacity="0.18" /> | |
| </g> | |
| </svg> | |
| </div> | |
| <!-- Wish Cards Grid --> | |
| <TransitionGroup :name="isMobileViewport ? '' : 'card-enter'" tag="div" class="wish-grid"> | |
| <div | |
| v-for="(wish, i) in wishStore.treeWishes" | |
| :key="wish.id" | |
| class="wish-card-slot" | |
| :class="[ | |
| CARD_COLORS[i % CARD_COLORS.length], | |
| { 'is-expired': wish.status === 'expired', 'is-fulfilled': wish.status === 'fulfilled' }, | |
| ]" | |
| @mouseenter="onCardEnter(wish, $event)" | |
| @mouseleave="onCardLeave" | |
| > | |
| <button type="button" class="cursor-pointer w-full" @click="openWishDetail(wish, $event)"> | |
| <span class="text-base">{{ STATUS_ICONS[wish.status] || '\u{1F331}' }}</span> | |
| <div class="mt-1 text-sm font-extrabold leading-tight truncate wish-card-title">{{ wish.animal_name }}</div> | |
| <div class="text-[11px] font-semibold opacity-80 wish-card-meta">W{{ wish.id }}</div> | |
| </button> | |
| </div> | |
| </TransitionGroup> | |
| <!-- Card Detail Popover --> | |
| <Transition name="popover"> | |
| <div | |
| v-if="popoverWish" | |
| class="card-popover" | |
| :style="{ left: popoverPos.x + 'px', top: popoverPos.y + 'px' }" | |
| @mouseenter="onPopoverEnter" | |
| @mouseleave="onPopoverLeave" | |
| > | |
| <div class="card-popover-arrow" /> | |
| <div class="p-4 text-sm space-y-1.5"> | |
| <div class="flex items-center gap-2 mb-2"> | |
| <span class="text-base">{{ STATUS_LABELS[popoverWish.status] || STATUS_LABELS.active }}</span> | |
| </div> | |
| <div><span class="text-text-muted">许愿的人:</span> <span class="font-semibold text-text-main">{{ popoverWish.animal_name }}</span></div> | |
| <div><span class="text-text-muted">TA的愿望:</span> {{ popoverWish.content }}</div> | |
| <div><span class="text-text-muted">创建时间:</span> {{ formatDateTime(popoverWish.created_at) }}</div> | |
| <div><span class="text-text-muted">过期时间:</span> {{ formatDateTime(popoverWish.expires_at) }}</div> | |
| <div v-if="popoverWish.ai_response" class="pt-2 mt-2 border-t border-gold/10 text-text-soft leading-relaxed italic"> | |
| "{{ popoverWish.ai_response }}" | |
| </div> | |
| <div v-if="isOwner" class="flex items-center gap-2 pt-2 mt-2 border-t border-gold/10"> | |
| <button | |
| v-if="canFulfill" | |
| type="button" | |
| class="px-3 h-7 rounded-md bg-amber-500 text-white text-xs font-medium hover:bg-amber-600 transition-colors" | |
| @click.stop="fulfillWish" | |
| > | |
| 还愿 | |
| </button> | |
| <button | |
| type="button" | |
| class="px-3 h-7 rounded-md bg-red-500 text-white text-xs font-medium hover:bg-red-600 transition-colors" | |
| @click.stop="deleteWish" | |
| > | |
| 删除 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </Transition> | |
| <Transition name="sheet"> | |
| <div v-if="popoverWish && isMobileViewport" class="mobile-detail-overlay" @click.self="closeWishDetail"> | |
| <div class="mobile-detail-sheet"> | |
| <div class="mobile-detail-handle" /> | |
| <div class="p-4 text-sm space-y-1.5"> | |
| <div class="flex items-center justify-between gap-3 mb-2"> | |
| <span class="text-base">{{ STATUS_LABELS[popoverWish.status] || STATUS_LABELS.active }}</span> | |
| <button type="button" class="text-text-muted text-lg leading-none" @click="closeWishDetail">×</button> | |
| </div> | |
| <div><span class="text-text-muted">许愿的人:</span> <span class="font-semibold text-text-main">{{ popoverWish.animal_name }}</span></div> | |
| <div><span class="text-text-muted">TA的愿望:</span> {{ popoverWish.content }}</div> | |
| <div><span class="text-text-muted">创建时间:</span> {{ formatDateTime(popoverWish.created_at) }}</div> | |
| <div><span class="text-text-muted">过期时间:</span> {{ formatDateTime(popoverWish.expires_at) }}</div> | |
| <div v-if="popoverWish.ai_response" class="pt-2 mt-2 border-t border-gold/10 text-text-soft leading-relaxed italic"> | |
| "{{ popoverWish.ai_response }}" | |
| </div> | |
| <div v-if="isOwner" class="flex items-center gap-2 pt-2 mt-2 border-t border-gold/10"> | |
| <button | |
| v-if="canFulfill" | |
| type="button" | |
| class="px-3 h-9 rounded-md bg-amber-500 text-white text-sm font-medium" | |
| @click.stop="fulfillWish" | |
| > | |
| 还愿 | |
| </button> | |
| <button | |
| type="button" | |
| class="px-3 h-9 rounded-md bg-red-500 text-white text-sm font-medium" | |
| @click.stop="deleteWish" | |
| > | |
| 删除 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </Transition> | |
| <!-- Pagination --> | |
| <div class="flex items-center justify-between gap-4 mt-6 px-1 pb-6"> | |
| <button | |
| type="button" | |
| class="px-3.5 h-10 rounded-lg bg-white/76 border border-gold/10 text-sm font-medium text-text-main hover:bg-white transition-colors disabled:opacity-40" | |
| :disabled="wishStore.treePage <= 1" | |
| @click="prevPage" | |
| > | |
| ← 上一页 | |
| </button> | |
| <span class="text-center text-text-soft text-sm font-bold flex-1"> | |
| {{ wishStore.treePage }}/{{ wishStore.treeTotalPages }} | |
| <span class="text-text-muted ml-2">共 {{ wishStore.treeTotal }} 个愿望</span> | |
| </span> | |
| <button | |
| type="button" | |
| class="px-3.5 h-10 rounded-lg bg-white/76 border border-gold/10 text-sm font-medium text-text-main hover:bg-white transition-colors disabled:opacity-40" | |
| :disabled="wishStore.treePage >= wishStore.treeTotalPages" | |
| @click="nextPage" | |
| > | |
| 下一页 → | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| <style scoped> | |
| /* Tree wallpaper background */ | |
| .tree-wallpaper { | |
| position: absolute; | |
| inset: 0; | |
| pointer-events: none; | |
| overflow: hidden; | |
| z-index: 0; | |
| } | |
| .tree-wallpaper-svg { | |
| width: 100%; | |
| height: 100%; | |
| color: #8bba7a; | |
| opacity: 0.6; | |
| filter: saturate(0.82) blur(0.2px); | |
| } | |
| .tree-wallpaper::after { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| background: | |
| linear-gradient(180deg, rgba(255, 250, 241, 0.12) 0%, rgba(255, 250, 241, 0.36) 100%), | |
| linear-gradient(90deg, rgba(255, 250, 241, 0.32) 0%, rgba(255, 250, 241, 0.08) 42%, rgba(255, 250, 241, 0.18) 100%); | |
| } | |
| /* Wish cards grid */ | |
| .wish-grid { | |
| position: relative; | |
| z-index: 1; | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); | |
| gap: 16px; | |
| padding: 16px 0; | |
| } | |
| .wish-card-slot { | |
| position: relative; | |
| } | |
| .wish-card-slot::before { | |
| content: ""; | |
| position: absolute; | |
| left: 50%; | |
| top: -34px; | |
| width: 2px; | |
| height: 36px; | |
| margin-left: -1px; | |
| background: linear-gradient(180deg, rgba(248, 229, 182, 0.96) 0%, rgba(151, 118, 52, 0.38) 100%); | |
| z-index: 0; | |
| } | |
| .wish-card-slot::after { | |
| content: ""; | |
| position: absolute; | |
| left: 50%; | |
| top: -6px; | |
| width: 12px; | |
| height: 12px; | |
| margin-left: -6px; | |
| border-radius: 999px; | |
| background: #f4d061; | |
| box-shadow: 0 1px 4px rgba(73, 52, 28, 0.18); | |
| z-index: 1; | |
| } | |
| .wish-card-slot button { | |
| min-height: 74px; | |
| padding: 10px 8px; | |
| border-radius: 12px; | |
| border: 1px solid rgba(67, 48, 31, 0.14); | |
| box-shadow: 0 12px 24px rgba(70, 50, 30, 0.14); | |
| font-weight: 700; | |
| white-space: normal; | |
| line-height: 1.2; | |
| width: 100%; | |
| text-align: left; | |
| background: none; | |
| backdrop-filter: blur(6px); | |
| -webkit-backdrop-filter: blur(6px); | |
| text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2); | |
| } | |
| /* Card colors */ | |
| .card-rose button { background: linear-gradient(135deg, rgba(255, 213, 222, 0.96) 0%, rgba(255, 235, 239, 0.98) 100%); color: #6d2436; } | |
| .card-amber button { background: linear-gradient(135deg, rgba(255, 228, 181, 0.96) 0%, rgba(255, 242, 215, 0.98) 100%); color: #71440a; } | |
| .card-mint button { background: linear-gradient(135deg, rgba(217, 245, 234, 0.96) 0%, rgba(239, 251, 246, 0.98) 100%); color: #1e5743; } | |
| .card-sky button { background: linear-gradient(135deg, rgba(216, 235, 255, 0.96) 0%, rgba(238, 246, 255, 0.98) 100%); color: #1a467d; } | |
| .card-violet button { background: linear-gradient(135deg, rgba(234, 220, 255, 0.96) 0%, rgba(245, 239, 255, 0.98) 100%); color: #51317e; } | |
| .card-peach button { background: linear-gradient(135deg, rgba(255, 220, 202, 0.96) 0%, rgba(255, 241, 232, 0.98) 100%); color: #7a3d21; } | |
| .wish-card-title { | |
| letter-spacing: 0.01em; | |
| } | |
| .wish-card-meta { | |
| color: rgba(47, 38, 29, 0.74); | |
| } | |
| /* Card hover popover */ | |
| .card-popover { | |
| position: fixed; | |
| z-index: 100; | |
| width: min(320px, calc(100vw - 32px)); | |
| transform: translateX(-50%); | |
| background: rgba(255, 253, 247, 0.98); | |
| border: 1px solid rgba(98, 73, 43, 0.12); | |
| border-radius: 16px; | |
| box-shadow: 0 20px 60px rgba(47, 38, 29, 0.18); | |
| backdrop-filter: blur(12px); | |
| } | |
| .card-popover-arrow { | |
| position: absolute; | |
| top: -6px; | |
| left: 50%; | |
| transform: translateX(-50%) rotate(45deg); | |
| width: 12px; | |
| height: 12px; | |
| background: rgba(255, 253, 247, 0.98); | |
| border-left: 1px solid rgba(98, 73, 43, 0.12); | |
| border-top: 1px solid rgba(98, 73, 43, 0.12); | |
| } | |
| /* Popover transition */ | |
| .popover-enter-active, | |
| .popover-leave-active { | |
| transition: opacity 0.15s ease, transform 0.15s ease; | |
| } | |
| .popover-enter-from, | |
| .popover-leave-to { | |
| opacity: 0; | |
| transform: translateX(-50%) translateY(4px); | |
| } | |
| .mobile-detail-overlay { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 110; | |
| background: rgba(26, 20, 12, 0.32); | |
| display: flex; | |
| align-items: flex-end; | |
| } | |
| .mobile-detail-sheet { | |
| width: 100%; | |
| background: rgba(255, 253, 247, 0.98); | |
| border-radius: 20px 20px 0 0; | |
| box-shadow: 0 -12px 36px rgba(47, 38, 29, 0.18); | |
| backdrop-filter: blur(12px); | |
| padding-bottom: max(16px, env(safe-area-inset-bottom)); | |
| } | |
| .mobile-detail-handle { | |
| width: 44px; | |
| height: 5px; | |
| border-radius: 999px; | |
| background: rgba(120, 96, 61, 0.24); | |
| margin: 10px auto 2px; | |
| } | |
| /* Card states */ | |
| .wish-card-slot.is-expired button { | |
| background: linear-gradient(135deg, #e6e1da 0%, #f4f1ed 100%) ; | |
| color: #7a7064 ; | |
| box-shadow: 0 8px 18px rgba(90, 80, 68, 0.1) ; | |
| filter: saturate(0.4); | |
| } | |
| .wish-card-slot.is-fulfilled button { | |
| background: linear-gradient(135deg, #dbead2 0%, #edf6e8 100%) ; | |
| color: #47613d ; | |
| border-color: rgba(71, 97, 61, 0.18) ; | |
| box-shadow: 0 12px 24px rgba(71, 97, 61, 0.14) ; | |
| } | |
| /* Mobile responsive */ | |
| @media (max-width: 768px) { | |
| .card-popover { | |
| display: none; | |
| } | |
| .wish-grid { | |
| grid-template-columns: repeat(auto-fill, minmax(98px, 1fr)); | |
| gap: 12px; | |
| } | |
| .wish-card-slot button { | |
| min-height: 64px; | |
| padding: 8px 6px; | |
| font-size: 0.78rem; | |
| } | |
| .wish-card-slot::before { | |
| height: 28px; | |
| top: -28px; | |
| } | |
| .wish-card-slot::after { | |
| width: 10px; | |
| height: 10px; | |
| top: -5px; | |
| margin-left: -5px; | |
| } | |
| .card-enter-enter-active, | |
| .card-enter-leave-active { | |
| position: relative; | |
| transition: none ; | |
| } | |
| .card-enter-enter-from, | |
| .card-enter-leave-to { | |
| opacity: 1; | |
| transform: none; | |
| } | |
| } | |
| .sheet-enter-active, | |
| .sheet-leave-active { | |
| transition: opacity 0.2s ease; | |
| } | |
| .sheet-enter-active .mobile-detail-sheet, | |
| .sheet-leave-active .mobile-detail-sheet { | |
| transition: transform 0.2s ease; | |
| } | |
| .sheet-enter-from, | |
| .sheet-leave-to { | |
| opacity: 0; | |
| } | |
| .sheet-enter-from .mobile-detail-sheet, | |
| .sheet-leave-to .mobile-detail-sheet { | |
| transform: translateY(100%); | |
| } | |
| /* Card enter animation */ | |
| .card-enter-enter-active { | |
| transition: opacity 0.18s ease, transform 0.18s ease; | |
| } | |
| .card-enter-enter-from { | |
| opacity: 0; | |
| transform: translateY(8px) scale(0.97); | |
| } | |
| .card-enter-leave-active { | |
| position: absolute; | |
| transition: opacity 0.14s ease; | |
| } | |
| .card-enter-leave-to { | |
| opacity: 0; | |
| } | |
| /* Filter dropdown transition */ | |
| .fade-enter-active, | |
| .fade-leave-active { | |
| transition: opacity 0.15s ease; | |
| } | |
| .fade-enter-from, | |
| .fade-leave-to { | |
| opacity: 0; | |
| } | |
| /* Mobile panel transition */ | |
| .slide-enter-active, | |
| .slide-leave-active { | |
| transition: opacity 0.2s ease; | |
| } | |
| .slide-enter-active > div, | |
| .slide-leave-active > div { | |
| transition: transform 0.2s ease; | |
| } | |
| .slide-enter-from, | |
| .slide-leave-to { | |
| opacity: 0; | |
| } | |
| .slide-enter-from > div, | |
| .slide-leave-to > div { | |
| transform: translateX(100%); | |
| } | |
| </style> | |