feat(deck): add deck shell, keyboard/hash navigation, and slide primitives
Deck.vue stage with progress dots, arrow controls and direction-aware transitions; useDeck composable (arrow/space/Home/End + URL-hash deep links); shared Icon, SlideFrame, PlaceholderBadge, LocationDetail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e73072318c
commit
adb6783a02
6 changed files with 385 additions and 0 deletions
147
src/deck/Deck.vue
Normal file
147
src/deck/Deck.vue
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
import { useDeck } from './useDeck'
|
||||
import { brand } from './data'
|
||||
|
||||
import TitleSlide from './slides/TitleSlide.vue'
|
||||
import VisionSlide from './slides/VisionSlide.vue'
|
||||
import NetworkSlide from './slides/NetworkSlide.vue'
|
||||
import TechSlide from './slides/TechSlide.vue'
|
||||
import LocationsSlide from './slides/LocationsSlide.vue'
|
||||
import CitySlide from './slides/CitySlide.vue'
|
||||
import FarmSlide from './slides/FarmSlide.vue'
|
||||
import RevenueSlide from './slides/RevenueSlide.vue'
|
||||
import InvestmentOverviewSlide from './slides/InvestmentOverviewSlide.vue'
|
||||
import CostBreakdownSlide from './slides/CostBreakdownSlide.vue'
|
||||
import AngelSlide from './slides/AngelSlide.vue'
|
||||
import ContributorSlide from './slides/ContributorSlide.vue'
|
||||
import RoadmapSlide from './slides/RoadmapSlide.vue'
|
||||
import AskSlide from './slides/AskSlide.vue'
|
||||
|
||||
const slides = [
|
||||
{ id: 'title', label: 'Title', component: TitleSlide },
|
||||
{ id: 'vision', label: 'Vision', component: VisionSlide },
|
||||
{ id: 'network', label: 'The network', component: NetworkSlide },
|
||||
{ id: 'tech', label: 'Technology', component: TechSlide },
|
||||
{ id: 'locations', label: 'Two nodes', component: LocationsSlide },
|
||||
{ id: 'city', label: 'City node', component: CitySlide },
|
||||
{ id: 'farm', label: 'Farm node', component: FarmSlide },
|
||||
{ id: 'revenue', label: 'Revenue', component: RevenueSlide },
|
||||
{ id: 'invest-overview', label: 'The raise', component: InvestmentOverviewSlide },
|
||||
{ id: 'cost-breakdown', label: 'Cost breakdown', component: CostBreakdownSlide },
|
||||
{ id: 'angel', label: 'Angel investment', component: AngelSlide },
|
||||
{ id: 'contribute', label: 'Contribute', component: ContributorSlide },
|
||||
{ id: 'roadmap', label: 'Roadmap', component: RoadmapSlide },
|
||||
{ id: 'ask', label: 'The ask', component: AskSlide },
|
||||
]
|
||||
|
||||
const { index, direction, goto, next, prev, isFirst, isLast } = useDeck(slides.length)
|
||||
const current = computed(() => slides[index.value])
|
||||
const transitionName = computed(() => (direction.value === 1 ? 'slide-next' : 'slide-prev'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="deck-root relative h-dvh w-full overflow-hidden">
|
||||
<!-- top bar -->
|
||||
<header
|
||||
class="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between px-6 py-5 text-sand-dim"
|
||||
>
|
||||
<span class="font-display text-sm font-semibold tracking-wide text-sand">
|
||||
{{ brand.name }}
|
||||
</span>
|
||||
<span class="text-xs uppercase tracking-[0.2em]">{{ current.label }}</span>
|
||||
</header>
|
||||
|
||||
<!-- slide stage -->
|
||||
<main class="h-full w-full">
|
||||
<Transition :name="transitionName" mode="out-in">
|
||||
<KeepAlive>
|
||||
<component :is="current.component" :key="current.id" class="h-full w-full" />
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</main>
|
||||
|
||||
<!-- controls -->
|
||||
<div class="absolute inset-x-0 bottom-0 z-20 flex items-center justify-between gap-4 px-6 py-5">
|
||||
<button
|
||||
class="pointer-events-auto grid size-10 place-items-center rounded-full border border-white/10 bg-white/5 text-sand transition hover:bg-white/10 disabled:opacity-30"
|
||||
:disabled="isFirst"
|
||||
aria-label="Previous slide"
|
||||
@click="prev"
|
||||
>
|
||||
<ChevronLeft class="size-5" />
|
||||
</button>
|
||||
|
||||
<!-- progress dots -->
|
||||
<nav class="flex flex-wrap items-center justify-center gap-2" aria-label="Slides">
|
||||
<button
|
||||
v-for="(s, i) in slides"
|
||||
:key="s.id"
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="i === index ? 'w-6 bg-amber-400' : 'w-1.5 bg-white/20 hover:bg-white/40'"
|
||||
:title="s.label"
|
||||
:aria-label="`Go to ${s.label}`"
|
||||
:aria-current="i === index"
|
||||
@click="goto(i)"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="hidden text-xs tabular-nums text-sand-dim sm:inline">
|
||||
{{ index + 1 }} / {{ slides.length }}
|
||||
</span>
|
||||
<button
|
||||
class="pointer-events-auto grid size-10 place-items-center rounded-full border border-white/10 bg-white/5 text-sand transition hover:bg-white/10 disabled:opacity-30"
|
||||
:disabled="isLast"
|
||||
aria-label="Next slide"
|
||||
@click="next"
|
||||
>
|
||||
<ChevronRight class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-next-enter-active,
|
||||
.slide-next-leave-active,
|
||||
.slide-prev-enter-active,
|
||||
.slide-prev-leave-active {
|
||||
transition:
|
||||
opacity 0.35s ease,
|
||||
transform 0.35s ease;
|
||||
}
|
||||
.slide-next-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(3%);
|
||||
}
|
||||
.slide-next-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-3%);
|
||||
}
|
||||
.slide-prev-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-3%);
|
||||
}
|
||||
.slide-prev-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(3%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.slide-next-enter-active,
|
||||
.slide-next-leave-active,
|
||||
.slide-prev-enter-active,
|
||||
.slide-prev-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.slide-next-enter-from,
|
||||
.slide-next-leave-to,
|
||||
.slide-prev-enter-from,
|
||||
.slide-prev-leave-to {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
62
src/deck/components/Icon.vue
Normal file
62
src/deck/components/Icon.vue
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<script setup lang="ts">
|
||||
/** Small registry so data.ts can reference icons by string name. */
|
||||
import {
|
||||
Wifi,
|
||||
Cpu,
|
||||
Code,
|
||||
Zap,
|
||||
KeyRound,
|
||||
Globe,
|
||||
Building2,
|
||||
Trees,
|
||||
BedDouble,
|
||||
Sparkles,
|
||||
Mountain,
|
||||
Palette,
|
||||
PartyPopper,
|
||||
Router,
|
||||
CreditCard,
|
||||
Origami,
|
||||
ArrowRight,
|
||||
Check,
|
||||
Mail,
|
||||
CalendarClock,
|
||||
Layers,
|
||||
Coins,
|
||||
HeartHandshake,
|
||||
type LucideProps,
|
||||
} from '@lucide/vue'
|
||||
import type { FunctionalComponent } from 'vue'
|
||||
|
||||
const registry: Record<string, FunctionalComponent<LucideProps>> = {
|
||||
Wifi,
|
||||
Cpu,
|
||||
Code,
|
||||
Zap,
|
||||
KeyRound,
|
||||
Globe,
|
||||
Building2,
|
||||
Trees,
|
||||
BedDouble,
|
||||
Sparkles,
|
||||
Mountain,
|
||||
Palette,
|
||||
PartyPopper,
|
||||
Router,
|
||||
CreditCard,
|
||||
Origami,
|
||||
ArrowRight,
|
||||
Check,
|
||||
Mail,
|
||||
CalendarClock,
|
||||
Layers,
|
||||
Coins,
|
||||
HeartHandshake,
|
||||
}
|
||||
|
||||
const props = defineProps<{ name: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="registry[props.name] ?? Layers" />
|
||||
</template>
|
||||
59
src/deck/components/LocationDetail.vue
Normal file
59
src/deck/components/LocationDetail.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<script setup lang="ts">
|
||||
import SlideFrame from './SlideFrame.vue'
|
||||
import Icon from './Icon.vue'
|
||||
import PlaceholderBadge from './PlaceholderBadge.vue'
|
||||
import type { Location } from '../data'
|
||||
|
||||
const props = defineProps<{ location: Location; accent: 'pine' | 'clay' }>()
|
||||
|
||||
const tone =
|
||||
props.accent === 'pine'
|
||||
? { text: 'text-pine-300', chip: 'bg-pine-500/10 text-pine-300', ring: 'border-pine-500/30' }
|
||||
: { text: 'text-clay-400', chip: 'bg-clay-400/10 text-clay-400', ring: 'border-clay-400/30' }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideFrame :kicker="location.kind">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-display text-4xl font-semibold text-sand lg:text-5xl">{{ location.name }}</h2>
|
||||
<PlaceholderBadge label="name" />
|
||||
</div>
|
||||
<p class="mt-4 max-w-2xl text-lg text-sand-dim">{{ location.blurb }}</p>
|
||||
|
||||
<div class="mt-10 grid gap-8 lg:grid-cols-[1fr_1fr]">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em]" :class="tone.text">
|
||||
Why it matters
|
||||
</p>
|
||||
<ul class="mt-4 space-y-3">
|
||||
<li
|
||||
v-for="h in location.highlights"
|
||||
:key="h"
|
||||
class="flex items-start gap-3 text-sand-dim"
|
||||
>
|
||||
<span class="mt-1 grid size-5 shrink-0 place-items-center rounded-full" :class="tone.chip">
|
||||
<Icon name="Check" class="size-3" />
|
||||
</span>
|
||||
{{ h }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border p-6" :class="tone.ring">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em]" :class="tone.text">
|
||||
Spaces on site
|
||||
</p>
|
||||
<div class="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="s in location.spaces"
|
||||
:key="s"
|
||||
class="flex items-center gap-2 rounded-lg bg-white/[0.03] px-3 py-2 text-sm text-sand"
|
||||
>
|
||||
<span class="size-1.5 rounded-full" :class="accent === 'pine' ? 'bg-pine-400' : 'bg-clay-400'" />
|
||||
{{ s }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SlideFrame>
|
||||
</template>
|
||||
13
src/deck/components/PlaceholderBadge.vue
Normal file
13
src/deck/components/PlaceholderBadge.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
/** Marks invented figures/copy so nothing placeholder ships unnoticed. */
|
||||
withDefaults(defineProps<{ label?: string }>(), { label: 'placeholder' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full border border-amber-400/30 bg-amber-400/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-amber-300"
|
||||
title="This value is a placeholder — edit src/deck/data.ts"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
39
src/deck/components/SlideFrame.vue
Normal file
39
src/deck/components/SlideFrame.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
/** Shared slide chrome: consistent padding, kicker, title, subtitle + body slot. */
|
||||
defineProps<{
|
||||
kicker?: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
center?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="mx-auto flex h-full w-full max-w-6xl flex-col px-8 py-14 sm:px-14 lg:px-20"
|
||||
:class="center ? 'items-center justify-center text-center' : 'justify-center'"
|
||||
>
|
||||
<p
|
||||
v-if="kicker"
|
||||
class="mb-3 text-xs font-semibold uppercase tracking-[0.25em] text-amber-400"
|
||||
>
|
||||
{{ kicker }}
|
||||
</p>
|
||||
<h2
|
||||
v-if="title"
|
||||
class="font-display text-3xl font-semibold leading-tight text-sand sm:text-4xl lg:text-5xl"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-4 max-w-2xl text-base text-sand-dim sm:text-lg"
|
||||
:class="center ? 'mx-auto' : ''"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<div :class="[title || kicker ? 'mt-10' : '', 'w-full']">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
65
src/deck/useDeck.ts
Normal file
65
src/deck/useDeck.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
|
||||
/**
|
||||
* Deck navigation: current slide index, next/prev/goto, keyboard control,
|
||||
* and URL-hash sync so any slide is a shareable deep link (…/#/7).
|
||||
*/
|
||||
export function useDeck(total: number) {
|
||||
const clamp = (n: number) => Math.min(Math.max(n, 0), total - 1)
|
||||
|
||||
const readHash = () => {
|
||||
const m = window.location.hash.match(/#\/(\d+)/)
|
||||
return m ? clamp(parseInt(m[1], 10) - 1) : 0
|
||||
}
|
||||
|
||||
const index = ref(0)
|
||||
const direction = ref<1 | -1>(1)
|
||||
|
||||
function goto(n: number) {
|
||||
const next = clamp(n)
|
||||
direction.value = next >= index.value ? 1 : -1
|
||||
index.value = next
|
||||
window.location.hash = `#/${next + 1}`
|
||||
}
|
||||
const next = () => goto(index.value + 1)
|
||||
const prev = () => goto(index.value - 1)
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
next()
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
|
||||
e.preventDefault()
|
||||
prev()
|
||||
} else if (e.key === 'Home') {
|
||||
goto(0)
|
||||
} else if (e.key === 'End') {
|
||||
goto(total - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const onHash = () => {
|
||||
index.value = readHash()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
index.value = readHash()
|
||||
if (!window.location.hash) window.location.hash = '#/1'
|
||||
window.addEventListener('keydown', onKey)
|
||||
window.addEventListener('hashchange', onHash)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('hashchange', onHash)
|
||||
})
|
||||
|
||||
return {
|
||||
index,
|
||||
direction,
|
||||
goto,
|
||||
next,
|
||||
prev,
|
||||
isFirst: computed(() => index.value === 0),
|
||||
isLast: computed(() => index.value === total - 1),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue