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