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:
Padreug 2026-05-27 11:13:48 +02:00
commit 35e5a01331
6 changed files with 173 additions and 2 deletions

View file

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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 }
}