feat: app shell — header, footer, layout, theme toggle
SiteHeader is sticky with backdrop blur, wordmark left and tracked uppercase nav right (Portfolio / Contact). Mobile breakpoint collapses to a Sheet drawer. ThemeToggle drops a class onto <html> and persists to localStorage; initial value respects the system prefers-color-scheme. SiteFooter is intentionally bare: wordmark, single Inquire link, copyright. No social, no phone, no address — the brief is anonymity-first. AppLayout composes the three plus Sonner's Toaster (used by the contact form later). App.vue now renders AppLayout instead of a bare RouterView. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
04ae000a12
commit
35e5a01331
6 changed files with 173 additions and 2 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<AppLayout />
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
17
src/components/layout/AppLayout.vue
Normal file
17
src/components/layout/AppLayout.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
import SiteHeader from './SiteHeader.vue'
|
||||||
|
import SiteFooter from './SiteFooter.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-background text-foreground flex min-h-screen flex-col">
|
||||||
|
<SiteHeader />
|
||||||
|
<main class="flex-1">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
<Toaster position="bottom-right" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
29
src/components/layout/SiteFooter.vue
Normal file
29
src/components/layout/SiteFooter.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
const year = new Date().getFullYear()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<footer class="border-border/60 border-t mt-24">
|
||||||
|
<div
|
||||||
|
class="mx-auto flex max-w-[1400px] flex-col items-start justify-between gap-6 px-6 py-12 md:flex-row md:items-center md:px-10"
|
||||||
|
>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="font-serif text-base tracking-[0.18em] uppercase">Earth Walker</p>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
Interior design — by appointment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-8">
|
||||||
|
<RouterLink
|
||||||
|
to="/contact"
|
||||||
|
class="text-foreground/70 hover:text-foreground text-xs uppercase tracking-[0.22em] transition-colors"
|
||||||
|
>
|
||||||
|
Inquire
|
||||||
|
</RouterLink>
|
||||||
|
<p class="text-muted-foreground text-xs">© {{ year }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
80
src/components/layout/SiteHeader.vue
Normal file
80
src/components/layout/SiteHeader.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { Menu } from '@lucide/vue'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
|
import ThemeToggle from './ThemeToggle.vue'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/portfolio', label: 'Portfolio' },
|
||||||
|
{ to: '/contact', label: 'Contact' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mobileOpen = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="bg-background/85 sticky top-0 z-40 w-full backdrop-blur-md supports-[backdrop-filter]:bg-background/70"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx-auto flex h-16 max-w-[1400px] items-center justify-between px-6 md:px-10"
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
to="/"
|
||||||
|
class="text-foreground font-serif text-lg tracking-[0.18em] uppercase"
|
||||||
|
>
|
||||||
|
Earth Walker
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<nav class="hidden items-center gap-10 md:flex">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
class="text-foreground/70 hover:text-foreground text-xs uppercase tracking-[0.22em] transition-colors"
|
||||||
|
active-class="text-foreground"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</RouterLink>
|
||||||
|
<ThemeToggle />
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 md:hidden">
|
||||||
|
<ThemeToggle />
|
||||||
|
<Sheet v-model:open="mobileOpen">
|
||||||
|
<SheetTrigger
|
||||||
|
class="text-foreground/70 hover:text-foreground inline-flex h-9 w-9 items-center justify-center"
|
||||||
|
aria-label="Open menu"
|
||||||
|
>
|
||||||
|
<Menu class="h-5 w-5" />
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" class="w-full sm:max-w-sm">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle class="font-serif tracking-[0.18em] uppercase">
|
||||||
|
Earth Walker
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<nav class="mt-10 flex flex-col gap-6 px-4">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
class="text-foreground font-serif text-2xl tracking-wide"
|
||||||
|
@click="mobileOpen = false"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
18
src/components/layout/ThemeToggle.vue
Normal file
18
src/components/layout/ThemeToggle.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Moon, Sun } from '@lucide/vue'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
||||||
|
const { theme, toggle } = useTheme()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-foreground/70 hover:text-foreground inline-flex h-9 w-9 items-center justify-center transition-colors"
|
||||||
|
:aria-label="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<Sun v-if="theme === 'dark'" class="h-4 w-4" />
|
||||||
|
<Moon v-else class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
27
src/composables/useTheme.ts
Normal file
27
src/composables/useTheme.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { ref, watchEffect } from 'vue'
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ewd:theme'
|
||||||
|
|
||||||
|
function initialTheme(): Theme {
|
||||||
|
if (typeof window === 'undefined') return 'light'
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored === 'light' || stored === 'dark') return stored
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = ref<Theme>(initialTheme())
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
document.documentElement.classList.toggle('dark', theme.value === 'dark')
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, theme.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
function toggle() {
|
||||||
|
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
return { theme, toggle }
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue