Browse Source

feat: 添加顶部导航组件,优化滚动动画,更新样式以支持自定义字体

beauty
npmrun 1 week ago
parent
commit
7c852288e2
  1. 29
      app/assets/css/main.css
  2. 238
      app/components/TopNav.vue
  3. 71
      app/composables/useFlipAnimation.ts
  4. 28
      app/composables/useGoogleFont.ts
  5. 12
      app/layouts/default.vue
  6. 11
      app/pages/index/index.vue

29
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);

238
app/components/TopNav.vue

@ -0,0 +1,238 @@
<script setup lang="ts">
const menuOpen = ref(false)
const fontReady = ref(false)
const navHidden = ref(false)
const route = useRoute()
const links = [
{ label: '发现', to: '/discover' },
{ label: '作品集', to: '/portfolio' },
{ label: '关于', to: '/about' },
]
let lastScrollY = 0
const HIDE_OFFSET = 80
function onScroll() {
const y = window.scrollY
if (y < HIDE_OFFSET) {
navHidden.value = false
} else if (y > lastScrollY + 8) {
navHidden.value = true
} else if (y < lastScrollY - 8) {
navHidden.value = false
}
lastScrollY = y
}
onMounted(async () => {
await useGoogleFont([{ family: 'Liu Jian Mao Cao', weight: 400 }])
fontReady.value = true
window.addEventListener('scroll', onScroll, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
})
watch(() => route.path, () => {
menuOpen.value = false
})
</script>
<template>
<nav class="top-nav" :style="{ transform: navHidden ? 'translateY(-100%)' : 'translateY(0)' }">
<div class="nav-inner">
<NuxtLink to="/" class="nav-logo" :class="{ ready: fontReady }">
Dash
</NuxtLink>
<ul class="nav-links" :class="{ open: menuOpen }">
<li v-for="link in links" :key="link.to">
<NuxtLink
:to="link.to"
class="nav-link"
active-class="active"
>
{{ link.label }}
</NuxtLink>
</li>
</ul>
<button
class="nav-toggle"
:class="{ open: menuOpen }"
aria-label="菜单"
@click="menuOpen = !menuOpen"
>
<span />
<span />
<span />
</button>
</div>
<Teleport to="body">
<div
v-if="menuOpen"
class="nav-overlay"
@click="menuOpen = false"
/>
</Teleport>
</nav>
</template>
<style scoped>
.top-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
height: 64px;
background: var(--color-canvas);
border-bottom: 1px solid var(--color-hairline);
transition: transform 0.35s ease;
will-change: transform;
}
.nav-inner {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
/* ── Logo ── */
.nav-logo {
font-family: 'Liu Jian Mao Cao', cursive;
font-size: 28px;
font-weight: 500;
color: var(--color-ink);
text-decoration: none;
line-height: 1;
opacity: 0;
transition: opacity 0.3s ease;
}
.nav-logo.ready {
opacity: 1;
}
/* ── Desktop links ── */
.nav-links {
display: flex;
gap: 32px;
list-style: none;
margin: 0;
padding: 0;
}
.nav-link {
font-family: var(--font-body);
font-size: 14px;
font-weight: 500;
color: var(--color-muted);
text-decoration: none;
transition: color 0.2s ease;
padding: 4px 0;
}
.nav-link:hover {
color: var(--color-ink);
}
.nav-link.active {
color: var(--color-ink);
}
/* ── Hamburger ── */
.nav-toggle {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 36px;
height: 36px;
background: none;
border: none;
cursor: pointer;
padding: 6px;
}
.nav-toggle span {
display: block;
height: 2px;
background: var(--color-ink);
border-radius: 1px;
transition: transform 0.25s ease, opacity 0.25s ease;
}
.nav-toggle.open span:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.nav-toggle.open span:nth-child(2) {
opacity: 0;
}
.nav-toggle.open span:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
/* ── Overlay ── */
.nav-overlay {
position: fixed;
inset: 0;
z-index: 99;
background: rgba(20, 20, 19, 0.3);
backdrop-filter: blur(4px);
}
/* ── Mobile ── */
@media (max-width: 768px) {
.nav-links {
position: fixed;
top: 64px;
right: 0;
z-index: 100;
flex-direction: column;
gap: 0;
width: 240px;
background: var(--color-canvas);
border-left: 1px solid var(--color-hairline);
border-bottom: 1px solid var(--color-hairline);
border-radius: 0 0 12px 0;
padding: 8px 0;
transform: translateX(100%);
transition: transform 0.3s ease;
}
.nav-links.open {
transform: translateX(0);
}
.nav-link {
display: block;
padding: 14px 24px;
font-size: 15px;
border-bottom: 1px solid var(--color-hairline-soft);
}
.nav-link:last-child {
border-bottom: none;
}
.nav-toggle {
display: flex;
}
}
</style>

71
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<Element, DOMRect>()
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 }
}

28
app/composables/useGoogleFont.ts

@ -0,0 +1,28 @@
interface FontOptions {
family: string
weight?: number
}
const loadedFamilies = new Set<string>()
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)
}

12
app/layouts/default.vue

@ -1,7 +1,15 @@
<template>
<NuxtPage />
<TopNav />
<div class="page-content">
<NuxtPage />
</div>
</template>
<script setup lang="ts">
</script>
</script>
<style>
.page-content {
padding-top: 64px;
}
</style>

11
app/pages/index/index.vue

@ -10,6 +10,9 @@ interface CardItem {
aspectRatio: number
}
const { capture, play } = useFlipAnimation()
const masonryEl = ref<HTMLElement | null>(null)
const allItems = ref<CardItem[]>([])
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) => {
<p class="subtitle">灵感与美学的无声对话</p>
</header>
<div class="masonry">
<div ref="masonryEl" class="masonry">
<div
v-for="(col, ci) in columns"
:key="ci"

Loading…
Cancel
Save