6 changed files with 386 additions and 3 deletions
@ -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> |
|||
@ -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 } |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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> |
|||
|
|||
Loading…
Reference in new issue