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) => {
灵感与美学的无声对话
-