feat: slide-deck engine and shared slide layouts

Keyboard-navigable full-screen deck (arrow keys, progress bar, hash
deep-linking that lands directly on the target slide) plus the reusable
Shell / Title / Bullets / Keys / Resources layout components.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
padreug 2026-07-22 02:53:45 +02:00
commit 88f8d6b681
7 changed files with 345 additions and 0 deletions

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
import SlideShell from './SlideShell.vue'
export interface Bullet {
/** Optional bold lead-in, rendered in white before the rest of the text. */
lead?: string
text: string
}
defineProps<{
kicker?: string
title: string
bullets: Bullet[]
footnote?: string
}>()
</script>
<template>
<SlideShell :kicker="kicker" :title="title">
<ul class="space-y-5">
<li v-for="(b, i) in bullets" :key="i" class="flex gap-4">
<span class="mt-2 h-2 w-2 flex-none rounded-full bg-violet-500" />
<span>
<span v-if="b.lead" class="mr-1.5 font-semibold text-white">{{ b.lead }}</span>
<span>{{ b.text }}</span>
</span>
</li>
</ul>
<p v-if="footnote" class="mt-10 border-l-2 border-violet-500/50 pl-4 text-base italic text-zinc-400">
{{ footnote }}
</p>
</SlideShell>
</template>

View file

@ -0,0 +1,40 @@
<script setup lang="ts">
import { Globe, KeyRound } from '@lucide/vue'
import SlideShell from './SlideShell.vue'
</script>
<template>
<SlideShell kicker="Your identity" title="No account — a key pair">
<div class="grid gap-6 sm:grid-cols-2">
<!-- Public key -->
<div class="rounded-xl border border-emerald-600/40 bg-emerald-950/20 p-6">
<div class="mb-3 flex items-center gap-3">
<Globe class="h-7 w-7 text-emerald-400" />
<span class="text-xl font-bold text-white">npub</span>
</div>
<p class="text-sm font-semibold uppercase tracking-wider text-emerald-400">Public key</p>
<p class="mt-2 text-zinc-300">Your username. Share it freely its how people find and follow you.</p>
<p class="mt-4 rounded-md bg-black/40 px-3 py-2 font-mono text-xs text-emerald-300/80">
npub1q9xshare&nbsp;me
</p>
</div>
<!-- Private key -->
<div class="rounded-xl border border-red-600/50 bg-red-950/20 p-6">
<div class="mb-3 flex items-center gap-3">
<KeyRound class="h-7 w-7 text-red-400" />
<span class="text-xl font-bold text-white">nsec</span>
</div>
<p class="text-sm font-semibold uppercase tracking-wider text-red-400">Private key</p>
<p class="mt-2 text-zinc-300">Your password. Whoever has it <em>is</em> you. Cant be reset. Never share it.</p>
<p class="mt-4 rounded-md bg-black/40 px-3 py-2 font-mono text-xs text-red-300/70">
nsec1
</p>
</div>
</div>
<p class="mt-8 text-zinc-300">
No email, no phone, no signup. Your keys work across <span class="font-semibold text-white">every</span> Nostr
app your follows and posts come with you.
</p>
</SlideShell>
</template>

View file

@ -0,0 +1,43 @@
<script setup lang="ts">
import { ExternalLink } from '@lucide/vue'
import SlideShell from './SlideShell.vue'
defineProps<{
kicker?: string
title: string
takeaways: string[]
links: { label: string; url: string }[]
}>()
</script>
<template>
<SlideShell :kicker="kicker" :title="title">
<div class="grid gap-8 sm:grid-cols-2">
<div>
<p class="mb-3 text-sm font-semibold uppercase tracking-wider text-violet-400">Remember</p>
<ul class="space-y-3">
<li v-for="(t, i) in takeaways" :key="i" class="flex gap-3">
<span class="mt-2 h-2 w-2 flex-none rounded-full bg-violet-500" />
<span>{{ t }}</span>
</li>
</ul>
</div>
<div>
<p class="mb-3 text-sm font-semibold uppercase tracking-wider text-violet-400">Go deeper</p>
<ul class="space-y-3">
<li v-for="link in links" :key="link.url">
<a
:href="link.url"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-2 text-zinc-200 underline decoration-violet-500/50 underline-offset-4 hover:text-violet-300"
>
<ExternalLink class="h-4 w-4 flex-none" />
{{ link.label }}
</a>
</li>
</ul>
</div>
</div>
</SlideShell>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
/**
* Common frame for every content slide: a small violet "kicker" (eyebrow),
* a big title, and a default slot for the body. Vertically centered, capped
* width so lines stay readable on a projector.
*/
defineProps<{
kicker?: string
title?: string
}>()
</script>
<template>
<div class="mx-auto flex h-full w-full max-w-5xl flex-col justify-center px-10 py-16">
<p
v-if="kicker"
class="mb-3 text-sm font-semibold uppercase tracking-[0.2em] text-violet-400"
>
{{ kicker }}
</p>
<h2
v-if="title"
class="mb-8 text-4xl font-bold leading-tight text-white sm:text-5xl"
>
{{ title }}
</h2>
<div class="text-lg leading-relaxed text-zinc-300 sm:text-xl">
<slot />
</div>
</div>
</template>

View file

@ -0,0 +1,32 @@
<script setup lang="ts">
/** Opening / hero slide. */
defineProps<{
title: string
subtitle?: string
tagline?: string
}>()
</script>
<template>
<div
class="relative mx-auto flex h-full w-full max-w-5xl flex-col items-center justify-center px-10 text-center"
>
<div
class="mb-8 flex h-20 w-20 items-center justify-center rounded-2xl bg-violet-600/20 ring-1 ring-violet-500/40"
>
<span class="text-4xl">🟣</span>
</div>
<h1 class="text-5xl font-extrabold tracking-tight text-white sm:text-7xl">
{{ title }}
</h1>
<p v-if="subtitle" class="mt-6 max-w-2xl text-xl text-zinc-300 sm:text-2xl">
{{ subtitle }}
</p>
<p
v-if="tagline"
class="mt-10 text-sm font-medium uppercase tracking-[0.25em] text-violet-400"
>
{{ tagline }}
</p>
</div>
</template>

View file

@ -0,0 +1,16 @@
import type { Component } from 'vue'
/**
* A slide is a layout component plus the props that feed it.
* The deck renders `<component :is="component" v-bind="props" />`, so any
* component under `src/components/slides/` can be dropped in here generic
* layouts for text-heavy slides, bespoke components for the diagram slides.
*/
export interface Slide {
/** Stable id used for the URL hash (e.g. #keys) and nav. */
id: string
/** Short label for the overview / progress tooltip. */
label: string
component: Component
props?: Record<string, unknown>
}

View file

@ -0,0 +1,150 @@
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { slides } from '@/features/slides/slides'
const count = slides.length
/** URL hash -> slide index, so any slide is deep-linkable (e.g. #outbox). */
function indexFromHash(): number {
const id = window.location.hash.replace(/^#\/?/, '')
if (!id) return 0
const byId = slides.findIndex((s) => s.id === id)
if (byId !== -1) return byId
const asNum = Number(id)
return Number.isFinite(asNum) ? Math.max(0, Math.min(count - 1, asNum - 1)) : 0
}
// Initialize from the hash before first paint so deep-links don't fade in from slide 1.
const current = ref(indexFromHash())
const activeSlide = computed(() => slides[current.value])
const progress = computed(() => ((current.value + 1) / count) * 100)
/** Track direction so the transition slides the right way. */
const dir = ref<'fwd' | 'back'>('fwd')
function go(index: number) {
const clamped = Math.max(0, Math.min(count - 1, index))
dir.value = clamped >= current.value ? 'fwd' : 'back'
current.value = clamped
}
function next() {
go(current.value + 1)
}
function prev() {
go(current.value - 1)
}
function onKey(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowRight':
case 'PageDown':
case ' ':
e.preventDefault()
next()
break
case 'ArrowLeft':
case 'PageUp':
e.preventDefault()
prev()
break
case 'Home':
e.preventDefault()
go(0)
break
case 'End':
e.preventDefault()
go(count - 1)
break
}
}
function onHashChange() {
const idx = indexFromHash()
if (idx !== current.value) go(idx)
}
watch(current, (i) => {
const id = slides[i].id
if (window.location.hash.replace(/^#\/?/, '') !== id) {
window.history.replaceState(null, '', `#${id}`)
}
})
onMounted(() => {
window.addEventListener('keydown', onKey)
window.addEventListener('hashchange', onHashChange)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
window.removeEventListener('hashchange', onHashChange)
})
</script>
<template>
<div class="dark relative h-screen w-screen overflow-hidden bg-zinc-950 text-white">
<!-- Slide viewport -->
<Transition :name="dir === 'fwd' ? 'slide-fwd' : 'slide-back'" mode="out-in">
<div :key="activeSlide.id" class="absolute inset-0 flex items-stretch">
<component :is="activeSlide.component" v-bind="activeSlide.props" />
</div>
</Transition>
<!-- Prev / next click zones (mobile + mouse) -->
<button
class="group absolute left-0 top-0 flex h-full w-16 items-center justify-center text-zinc-600 transition hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-0"
:disabled="current === 0"
aria-label="Previous slide"
@click="prev"
>
<ChevronLeft class="h-8 w-8" />
</button>
<button
class="group absolute right-0 top-0 flex h-full w-16 items-center justify-center text-zinc-600 transition hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-0"
:disabled="current === count - 1"
aria-label="Next slide"
@click="next"
>
<ChevronRight class="h-8 w-8" />
</button>
<!-- Bottom bar: counter + progress -->
<div class="absolute inset-x-0 bottom-0">
<div class="flex items-center justify-between px-6 pb-3 text-xs text-zinc-500">
<span class="font-medium tracking-wide">Intro to Nostr</span>
<span class="tabular-nums">{{ current + 1 }} / {{ count }}</span>
</div>
<div class="h-1 w-full bg-zinc-800">
<div class="h-full bg-violet-500 transition-all duration-300" :style="{ width: progress + '%' }" />
</div>
</div>
</div>
</template>
<style scoped>
.slide-fwd-enter-active,
.slide-fwd-leave-active,
.slide-back-enter-active,
.slide-back-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
.slide-fwd-enter-from {
opacity: 0;
transform: translateX(3%);
}
.slide-fwd-leave-to {
opacity: 0;
transform: translateX(-3%);
}
.slide-back-enter-from {
opacity: 0;
transform: translateX(-3%);
}
.slide-back-leave-to {
opacity: 0;
transform: translateX(3%);
}
</style>