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:
parent
a70c50826d
commit
88f8d6b681
7 changed files with 345 additions and 0 deletions
33
src/components/slides/BulletsSlide.vue
Normal file
33
src/components/slides/BulletsSlide.vue
Normal 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>
|
||||
40
src/components/slides/KeysSlide.vue
Normal file
40
src/components/slides/KeysSlide.vue
Normal 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 — it’s 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">
|
||||
npub1q9x…share 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. Can’t 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>
|
||||
43
src/components/slides/ResourcesSlide.vue
Normal file
43
src/components/slides/ResourcesSlide.vue
Normal 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>
|
||||
31
src/components/slides/SlideShell.vue
Normal file
31
src/components/slides/SlideShell.vue
Normal 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>
|
||||
32
src/components/slides/TitleSlide.vue
Normal file
32
src/components/slides/TitleSlide.vue
Normal 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>
|
||||
16
src/features/slides/types.ts
Normal file
16
src/features/slides/types.ts
Normal 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>
|
||||
}
|
||||
150
src/views/PresentationView.vue
Normal file
150
src/views/PresentationView.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue