diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 64f1e42..653dc47 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -29,6 +29,35 @@ body { font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } +/* ── Scrollbar ── */ + +html { + scrollbar-width: auto; + scrollbar-color: rgba(232, 224, 210, 0.55) transparent; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(232, 224, 210, 0.55); + border-radius: 9999px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(204, 120, 92, 0.30); +} + +::-webkit-scrollbar-corner { + background: transparent; +} + :root { --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); diff --git a/app/components/TopNav.vue b/app/components/TopNav.vue new file mode 100644 index 0000000..180ffff --- /dev/null +++ b/app/components/TopNav.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/app/composables/useFlipAnimation.ts b/app/composables/useFlipAnimation.ts new file mode 100644 index 0000000..49f83d3 --- /dev/null +++ b/app/composables/useFlipAnimation.ts @@ -0,0 +1,71 @@ +interface FlipEntry { + el: Element + rect: DOMRect +} + +let cachedReducedMotion: boolean | null = null +function prefersReducedMotion(): boolean { + if (cachedReducedMotion === null) { + cachedReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + } + return cachedReducedMotion +} + +export function useFlipAnimation() { + let firstEntries: FlipEntry[] | null = null + let runningAnims: Animation[] = [] + + function capture(container: HTMLElement, selector: string) { + cancelRunning() + firstEntries = [] + container.querySelectorAll(selector).forEach((el) => { + firstEntries!.push({ el, rect: el.getBoundingClientRect() }) + }) + } + + function play( + container: HTMLElement, + selector: string, + options: { duration?: number; easing?: string } = {}, + ) { + if (!firstEntries || prefersReducedMotion()) { + firstEntries = null + return + } + + const { duration = 420, easing = 'cubic-bezier(0.16, 1, 0.3, 1)' } = options + const firstByEl = new Map() + for (const entry of firstEntries) { + firstByEl.set(entry.el, entry.rect) + } + + container.querySelectorAll(selector).forEach((el) => { + const first = firstByEl.get(el) + if (!first) return + + const last = el.getBoundingClientRect() + const dx = first.left - last.left + const dy = first.top - last.top + + if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return + + const anim = el.animate( + [ + { transform: `translate(${dx}px, ${dy}px)`, opacity: '1' }, + { transform: 'translate(0, 0)', opacity: '1' }, + ], + { duration, easing, fill: 'both' }, + ) + runningAnims.push(anim) + }) + + firstEntries = null + } + + function cancelRunning() { + for (const a of runningAnims) a.cancel() + runningAnims = [] + } + + return { capture, play, cancelRunning } +} diff --git a/app/composables/useGoogleFont.ts b/app/composables/useGoogleFont.ts new file mode 100644 index 0000000..3428130 --- /dev/null +++ b/app/composables/useGoogleFont.ts @@ -0,0 +1,28 @@ +interface FontOptions { + family: string + weight?: number +} + +const loadedFamilies = new Set() + +export function useGoogleFont(fonts: FontOptions[]) { + const families = fonts.map((f) => { + const w = f.weight ? `:wght@${f.weight}` : '' + return `family=${encodeURIComponent(f.family)}${w}` + }) + + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = `https://fonts.googleapis.com/css2?${families.join('&')}&display=swap` + + const pending = fonts + .filter((f) => !loadedFamilies.has(f.family)) + .map((f) => document.fonts.load(`${f.weight || 400} 1em ${f.family}`)) + + if (pending.length > 0) { + document.head.appendChild(link) + for (const f of fonts) loadedFamilies.add(f.family) + } + + return Promise.all(pending) +} diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 379297e..273d6c0 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -1,7 +1,15 @@ - \ No newline at end of file + diff --git a/app/pages/index/index.vue b/app/pages/index/index.vue index ca58f4c..abadc4a 100644 --- a/app/pages/index/index.vue +++ b/app/pages/index/index.vue @@ -10,6 +10,9 @@ interface CardItem { aspectRatio: number } +const { capture, play } = useFlipAnimation() +const masonryEl = ref(null) + const allItems = ref([]) const page = ref(1) const hasMore = ref(true) @@ -77,8 +80,14 @@ function updateColumns() { else n = 5 if (n !== columnCount.value) { + if (masonryEl.value) capture(masonryEl.value, '.card-reveal') columnCount.value = n distributeAll() + nextTick(() => { + if (masonryEl.value) { + play(masonryEl.value, '.card-reveal', { duration: 420 }) + } + }) } } @@ -144,7 +153,7 @@ watch(sentinel, (el) => {

灵感与美学的无声对话

-
+