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> |
<template> |
||||
<NuxtPage /> |
<TopNav /> |
||||
|
<div class="page-content"> |
||||
|
<NuxtPage /> |
||||
|
</div> |
||||
</template> |
</template> |
||||
|
|
||||
<script setup lang="ts"> |
<script setup lang="ts"> |
||||
|
</script> |
||||
|
|
||||
</script> |
<style> |
||||
|
.page-content { |
||||
|
padding-top: 64px; |
||||
|
} |
||||
|
</style> |
||||
|
|||||
Loading…
Reference in new issue