Compare commits
No commits in common. "abbfcaf3ef8c7ae15b1454e1e5f69cfdb0545417" and "0054f3ab805d8c1146f36f164b19a75ec19cc86d" have entirely different histories.
abbfcaf3ef
...
0054f3ab80
19 changed files with 2 additions and 1256 deletions
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": ["shadcn-vue@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
allowBuilds:
|
||||
vue-demi: false
|
||||
verifyDepsBeforeRun: false
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { PenLine, Hash, KeyRound, ArrowRight } from '@lucide/vue'
|
||||
import SlideShell from './SlideShell.vue'
|
||||
|
||||
// Each field of a NIP-01 event: the key, a sample value, and what it means.
|
||||
// `derived` fields aren't typed by you — they're computed when the event is signed.
|
||||
interface Field {
|
||||
key: string
|
||||
value: string
|
||||
kind: 'string' | 'number' | 'array'
|
||||
comment: string
|
||||
derived?: boolean
|
||||
}
|
||||
|
||||
const fields: Field[] = [
|
||||
{ key: 'pubkey', value: '"3bf0c6…2a"', kind: 'string', comment: 'who wrote it — your public key' },
|
||||
{ key: 'created_at', value: '1721600000', kind: 'number', comment: 'when — unix seconds' },
|
||||
{ key: 'kind', value: '1', kind: 'number', comment: '1 = short text note' },
|
||||
{ key: 'tags', value: '[["e","…"],["p","…"]]', kind: 'array', comment: 'references: reply-to, mentions' },
|
||||
{ key: 'content', value: '"gm nostr ⚡"', kind: 'string', comment: 'the message itself' },
|
||||
{ key: 'id', value: '"0a1b2c…9f"', kind: 'string', comment: 'sha256 of the fields above', derived: true },
|
||||
{ key: 'sig', value: '"d4e5…c7"', kind: 'string', comment: 'Schnorr sig over id — proof it’s you', derived: true },
|
||||
]
|
||||
|
||||
const valueClass = { string: 'text-emerald-300', number: 'text-amber-300', array: 'text-zinc-300' }
|
||||
|
||||
const steps = [
|
||||
{ icon: PenLine, label: 'Fill the fields', sub: 'pubkey · kind · tags · content · time' },
|
||||
{ icon: Hash, label: 'Hash them → id', sub: 'sha256 of the serialized event' },
|
||||
{ icon: KeyRound, label: 'Sign id → sig', sub: 'Schnorr signature with your nsec' },
|
||||
]
|
||||
|
||||
// Staged reveal: typed fields first, then `id` is hashed in, then `sig` is signed in.
|
||||
// stage 0 = fields only · 1 = id computed · 2 = sig computed.
|
||||
const stage = ref(0)
|
||||
const timers: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function isRevealed(f: Field): boolean {
|
||||
if (!f.derived) return true
|
||||
if (f.key === 'id') return stage.value >= 1
|
||||
return stage.value >= 2 // sig
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
timers.forEach(clearTimeout)
|
||||
timers.length = 0
|
||||
}
|
||||
|
||||
// Click a step to jump to (and replay) that state; stops the auto-play so
|
||||
// manual control wins.
|
||||
function selectStage(i: number) {
|
||||
clearTimers()
|
||||
stage.value = i
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
if (reduce) {
|
||||
stage.value = 2
|
||||
return
|
||||
}
|
||||
timers.push(setTimeout(() => (stage.value = 1), 900))
|
||||
timers.push(setTimeout(() => (stage.value = 2), 1800))
|
||||
})
|
||||
onBeforeUnmount(clearTimers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideShell kicker="NIP-01 · the atom of Nostr" title="Anatomy of an event">
|
||||
<!-- The event, as JSON, hugged by big nostr-purple braces -->
|
||||
<div class="flex items-center justify-center gap-3 sm:gap-6">
|
||||
<span class="select-none text-[9rem] font-thin leading-none text-violet-500/80 sm:text-[11rem]">{</span>
|
||||
|
||||
<div class="grid grid-cols-[auto_auto_1fr] items-center gap-x-5 gap-y-1.5 font-mono text-base sm:text-lg">
|
||||
<template v-for="(f, i) in fields" :key="f.key">
|
||||
<span
|
||||
class="justify-self-end"
|
||||
:class="f.derived ? 'font-semibold text-violet-300' : 'text-violet-300'"
|
||||
>
|
||||
"{{ f.key }}"<span class="text-zinc-500">:</span>
|
||||
</span>
|
||||
<span class="whitespace-nowrap" :class="valueClass[f.kind]">
|
||||
<template v-if="isRevealed(f)">
|
||||
<span :class="{ 'reveal-flash': f.derived }">{{ f.value }}</span
|
||||
><span v-if="i < fields.length - 1" class="text-zinc-500">,</span>
|
||||
</template>
|
||||
<span v-else class="italic text-zinc-600">{{
|
||||
f.key === 'id' ? 'hashing…' : 'signing…'
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="whitespace-nowrap text-sm text-zinc-600">
|
||||
<span class="text-zinc-500">// </span>{{ f.comment
|
||||
}}<span
|
||||
v-if="f.derived && isRevealed(f)"
|
||||
class="reveal-flash ml-1 rounded bg-violet-500/15 px-1 text-xs text-violet-300"
|
||||
>computed</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<span class="select-none text-[9rem] font-thin leading-none text-violet-500/80 sm:text-[11rem]">}</span>
|
||||
</div>
|
||||
|
||||
<!-- How it's built & signed -->
|
||||
<div class="mt-8 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-center">
|
||||
<template v-for="(s, i) in steps" :key="s.label">
|
||||
<button
|
||||
type="button"
|
||||
:aria-current="i === stage ? 'step' : undefined"
|
||||
class="flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors duration-300 hover:border-violet-500/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
|
||||
:class="
|
||||
i === stage
|
||||
? 'border-violet-500 bg-violet-950/30 ring-1 ring-violet-500/40'
|
||||
: 'border-zinc-700 bg-zinc-900/60 hover:bg-zinc-900'
|
||||
"
|
||||
@click="selectStage(i)"
|
||||
>
|
||||
<component
|
||||
:is="s.icon"
|
||||
class="h-6 w-6 flex-none transition-colors duration-300"
|
||||
:class="i === stage ? 'text-violet-300' : 'text-violet-400/60'"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-semibold text-white">{{ s.label }}</p>
|
||||
<p class="text-xs text-zinc-400">{{ s.sub }}</p>
|
||||
</div>
|
||||
</button>
|
||||
<ArrowRight
|
||||
v-if="i < steps.length - 1"
|
||||
class="mx-auto h-5 w-5 flex-none rotate-90 text-zinc-500 sm:rotate-0"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<p class="mt-3 text-center text-xs text-zinc-600">Click a step to walk through each state.</p>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-zinc-500">
|
||||
Anyone recomputes the hash and checks <code class="text-zinc-300">sig</code> against your
|
||||
<code class="text-zinc-300">pubkey</code> — tamper with one character and it fails. No server vouches for you; the math does.
|
||||
</p>
|
||||
</SlideShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reveal-flash {
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
animation: reveal 0.6s ease-out;
|
||||
}
|
||||
@keyframes reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
filter: blur(5px);
|
||||
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.35);
|
||||
background: rgba(139, 92, 246, 0.35);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal-flash {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { PenLine, ArrowRight, Server, Users } from '@lucide/vue'
|
||||
import SlideShell from './SlideShell.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideShell kicker="The key idea" title="The outbox model (a.k.a. gossip)">
|
||||
<!-- Flow: you write → your outbox relays → followers read -->
|
||||
<div class="flex flex-col items-stretch gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-1 flex-col items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/60 p-5 text-center">
|
||||
<PenLine class="h-8 w-8 text-violet-400" />
|
||||
<p class="font-semibold text-white">You post</p>
|
||||
<p class="text-sm text-zinc-400">A signed note</p>
|
||||
</div>
|
||||
|
||||
<ArrowRight class="mx-auto h-6 w-6 rotate-90 text-zinc-500 sm:rotate-0" />
|
||||
|
||||
<div class="flex flex-1 flex-col items-center gap-2 rounded-xl border border-violet-600/50 bg-violet-950/20 p-5 text-center">
|
||||
<Server class="h-8 w-8 text-violet-300" />
|
||||
<p class="font-semibold text-white">Your <span class="text-violet-300">write / outbox</span> relays</p>
|
||||
<p class="text-sm text-zinc-400">Where your content lives</p>
|
||||
</div>
|
||||
|
||||
<ArrowRight class="mx-auto h-6 w-6 rotate-90 text-zinc-500 sm:rotate-0" />
|
||||
|
||||
<div class="flex flex-1 flex-col items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/60 p-5 text-center">
|
||||
<Users class="h-8 w-8 text-violet-400" />
|
||||
<p class="font-semibold text-white">Followers read</p>
|
||||
<p class="text-sm text-zinc-400">Their app looks there</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 space-y-4">
|
||||
<p>
|
||||
Your app <span class="font-semibold text-white">announces</span> which relays you write to.
|
||||
Other people’s apps read that list and go fetch your notes <span class="text-white">from the right place</span> —
|
||||
no central server required.
|
||||
</p>
|
||||
<p class="rounded-lg border-l-2 border-violet-500 bg-zinc-900/50 py-3 pl-4 pr-3 text-zinc-300">
|
||||
“If you don’t know where your posts are, you might as well just stay on centralized Twitter.”
|
||||
<span class="text-zinc-500">— Vitor Pamplona, Amethyst</span>
|
||||
</p>
|
||||
</div>
|
||||
</SlideShell>
|
||||
</template>
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import SlideShell from './SlideShell.vue'
|
||||
|
||||
// HTTP: three users funnel into ONE server.
|
||||
const users = [40, 95, 150]
|
||||
|
||||
// Nostr: you broadcast out to MANY relays (a little mesh).
|
||||
const you = { x: 52, y: 95 }
|
||||
const relays = [
|
||||
{ x: 200, y: 32 },
|
||||
{ x: 250, y: 62 },
|
||||
{ x: 262, y: 112 },
|
||||
{ x: 232, y: 158 },
|
||||
{ x: 182, y: 128 },
|
||||
]
|
||||
// Faint relay-to-relay links to imply a gossiping network.
|
||||
const mesh = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
[3, 4],
|
||||
[4, 0],
|
||||
[1, 4],
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideShell kicker="Mental model" title="Nostr isn’t HTTP">
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
<!-- HTTP: many users → one server -->
|
||||
<div class="rounded-xl border border-zinc-700 bg-zinc-900/60 p-6">
|
||||
<p class="mb-2 text-sm font-semibold uppercase tracking-widest text-zinc-400">
|
||||
The web (HTTP)
|
||||
</p>
|
||||
<svg viewBox="0 0 300 190" class="mx-auto w-full max-w-[300px]">
|
||||
<!-- connections -->
|
||||
<g stroke="#52525b" stroke-width="2">
|
||||
<line v-for="y in users" :key="'l' + y" x1="54" :y1="y" x2="206" y2="95" />
|
||||
</g>
|
||||
<!-- users -->
|
||||
<g fill="#a1a1aa">
|
||||
<template v-for="y in users" :key="'u' + y">
|
||||
<circle cx="42" :cy="y - 7" r="6" />
|
||||
<path :d="`M33 ${y + 9} a9 9 0 0 1 18 0`" />
|
||||
</template>
|
||||
</g>
|
||||
<!-- one server -->
|
||||
<rect x="206" y="66" width="66" height="58" rx="8" fill="#27272a" stroke="#a1a1aa" stroke-width="2" />
|
||||
<g stroke="#a1a1aa" stroke-width="2">
|
||||
<line x1="218" y1="84" x2="260" y2="84" />
|
||||
<line x1="218" y1="95" x2="260" y2="95" />
|
||||
<line x1="218" y1="106" x2="260" y2="106" />
|
||||
</g>
|
||||
<circle cx="252" cy="118" r="2.5" fill="#ef4444" />
|
||||
<text x="239" y="150" text-anchor="middle" fill="#71717a" font-size="12">single point</text>
|
||||
</svg>
|
||||
<p class="mt-2 text-center text-sm text-zinc-400">
|
||||
One server holds your account <em>and</em> your data. It bans you? You’re gone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Nostr: you fan out to many relays -->
|
||||
<div class="rounded-xl border border-violet-600/50 bg-violet-950/20 p-6">
|
||||
<p class="mb-2 text-sm font-semibold uppercase tracking-widest text-violet-400">Nostr</p>
|
||||
<svg viewBox="0 0 300 190" class="mx-auto w-full max-w-[300px]">
|
||||
<!-- faint relay mesh -->
|
||||
<g stroke="#5b21b6" stroke-width="1.5" opacity="0.6">
|
||||
<line
|
||||
v-for="([a, b], i) in mesh"
|
||||
:key="'m' + i"
|
||||
:x1="relays[a].x"
|
||||
:y1="relays[a].y"
|
||||
:x2="relays[b].x"
|
||||
:y2="relays[b].y"
|
||||
/>
|
||||
</g>
|
||||
<!-- broadcast lines (animated) -->
|
||||
<g stroke="#8b5cf6" stroke-width="2" class="flow">
|
||||
<line
|
||||
v-for="(r, i) in relays"
|
||||
:key="'b' + i"
|
||||
:x1="you.x + 14"
|
||||
:y1="you.y"
|
||||
:x2="r.x"
|
||||
:y2="r.y"
|
||||
/>
|
||||
</g>
|
||||
<!-- relays -->
|
||||
<g>
|
||||
<circle
|
||||
v-for="(r, i) in relays"
|
||||
:key="'r' + i"
|
||||
:cx="r.x"
|
||||
:cy="r.y"
|
||||
r="11"
|
||||
fill="#2e1065"
|
||||
stroke="#a78bfa"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</g>
|
||||
<!-- you (signed) -->
|
||||
<g fill="#8b5cf6">
|
||||
<circle :cx="you.x" :cy="you.y - 8" r="8" />
|
||||
<path :d="`M${you.x - 12} ${you.y + 10} a12 12 0 0 1 24 0`" />
|
||||
</g>
|
||||
<text :x="you.x" :y="you.y + 34" text-anchor="middle" fill="#a78bfa" font-size="12">you</text>
|
||||
<text x="235" y="184" text-anchor="middle" fill="#a78bfa" font-size="12">many relays</text>
|
||||
</svg>
|
||||
<p class="mt-2 text-center text-sm text-zinc-300">
|
||||
You sign notes and broadcast to relays you choose. One drops you? The rest still have it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-sm text-zinc-500">
|
||||
Relays actually talk over <code class="text-zinc-300">wss://</code> (WebSockets) — same plumbing
|
||||
as the web. What flips is
|
||||
<span class="text-zinc-300">who owns your identity and where your data lives.</span>
|
||||
</p>
|
||||
</SlideShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flow line {
|
||||
stroke-dasharray: 5 6;
|
||||
animation: flow 0.9s linear infinite;
|
||||
}
|
||||
@keyframes flow {
|
||||
to {
|
||||
stroke-dashoffset: -11;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.flow line {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import SlideShell from './SlideShell.vue'
|
||||
|
||||
const rows = [
|
||||
{ label: 'Identity', http: 'An account the server owns', nostr: 'A keypair you own' },
|
||||
{ label: 'Data lives', http: 'On one company’s server', nostr: 'Copied across many relays' },
|
||||
{ label: 'Model', http: 'Request → response', nostr: 'Publish signed events (pub/sub)' },
|
||||
{ label: 'Trust', http: 'Trust the host not to lie', nostr: 'Signatures — math, not trust' },
|
||||
{ label: 'If it bans you', http: 'You’re gone', nostr: 'Move relays, keep your identity' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideShell kicker="Mental model" title="HTTP vs Nostr, point by point">
|
||||
<div class="overflow-hidden rounded-xl border border-zinc-700">
|
||||
<table class="w-full text-left text-lg">
|
||||
<thead class="bg-zinc-800/60 text-sm uppercase tracking-wider text-zinc-400">
|
||||
<tr>
|
||||
<th class="px-5 py-3 font-medium"></th>
|
||||
<th class="px-5 py-3 font-medium">HTTP</th>
|
||||
<th class="px-5 py-3 font-medium text-violet-300">Nostr</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-800">
|
||||
<tr v-for="row in rows" :key="row.label">
|
||||
<td class="px-5 py-4 font-semibold text-white">{{ row.label }}</td>
|
||||
<td class="px-5 py-4 text-zinc-400">{{ row.http }}</td>
|
||||
<td class="px-5 py-4 text-zinc-200">{{ row.nostr }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SlideShell>
|
||||
</template>
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Home, Bell, Mail } from '@lucide/vue'
|
||||
import SlideShell from './SlideShell.vue'
|
||||
|
||||
const rows = [
|
||||
{
|
||||
icon: Home,
|
||||
role: 'Outbox',
|
||||
writes: 'You',
|
||||
reads: 'Everyone',
|
||||
empty: 'Followers never get your posts',
|
||||
},
|
||||
{
|
||||
icon: Bell,
|
||||
role: 'Inbox',
|
||||
writes: 'Everyone',
|
||||
reads: 'You',
|
||||
empty: 'You miss replies & mentions',
|
||||
},
|
||||
{
|
||||
icon: Mail,
|
||||
role: 'DM inbox',
|
||||
writes: 'Anyone (encrypted)',
|
||||
reads: 'Only you',
|
||||
empty: 'You miss DMs',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideShell kicker="Amethyst — why the sets differ" title="One relay can’t do every job">
|
||||
<div class="overflow-hidden rounded-xl border border-zinc-700">
|
||||
<table class="w-full text-left text-base">
|
||||
<thead class="bg-zinc-800/60 text-sm uppercase tracking-wider text-zinc-400">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Role</th>
|
||||
<th class="px-4 py-3 font-medium">Who writes</th>
|
||||
<th class="px-4 py-3 font-medium">Who reads</th>
|
||||
<th class="px-4 py-3 font-medium text-red-300">If you leave it empty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-800">
|
||||
<tr v-for="row in rows" :key="row.role">
|
||||
<td class="px-4 py-3 font-semibold text-white">
|
||||
<span class="flex items-center gap-2">
|
||||
<component :is="row.icon" class="h-5 w-5 text-violet-400" />
|
||||
{{ row.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-300">{{ row.writes }}</td>
|
||||
<td class="px-4 py-3 text-zinc-300">{{ row.reads }}</td>
|
||||
<td class="px-4 py-3 text-red-300/90">{{ row.empty }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="mt-6">
|
||||
The access rules are <span class="font-semibold text-white">opposite</span>, so no single relay
|
||||
fits every list — that’s the whole reason for separate sets.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 rounded-lg border-l-2 border-violet-500 bg-zinc-900/50 py-3 pl-4 pr-3 text-zinc-300">
|
||||
<span class="font-semibold text-white">Reuse vs. keep separate:</span>
|
||||
an <em>open</em> relay (e.g. <code class="text-zinc-200">nos.lol</code>) is fine in several lists at once.
|
||||
A <em>paid / restricted</em> relay (e.g. <code class="text-zinc-200">nostr.wine</code>) must stay in its lane —
|
||||
great for DMs, wrong for your public inbox.
|
||||
</div>
|
||||
</SlideShell>
|
||||
</template>
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Home, Bell, Lock, Mail, Search, HardDrive } from '@lucide/vue'
|
||||
import SlideShell from './SlideShell.vue'
|
||||
|
||||
const categories = [
|
||||
{
|
||||
icon: Home,
|
||||
name: 'Public Home / Outbox',
|
||||
count: '~3',
|
||||
what: 'Where your posts, likes & replies live. Followers read from here.',
|
||||
tip: 'One big public relay + one personal + one fast/country relay.',
|
||||
},
|
||||
{
|
||||
icon: Bell,
|
||||
name: 'Public Inbox',
|
||||
count: '~3',
|
||||
what: 'Where replies, mentions & zaps arrive — your notifications.',
|
||||
tip: 'Keep separate from outbox. Avoid nostr.wine variants here.',
|
||||
},
|
||||
{
|
||||
icon: Mail,
|
||||
name: 'DM Inbox',
|
||||
count: '~3',
|
||||
what: 'Encrypted direct messages. Write-open, read-restricted to you.',
|
||||
tip: 'inbox.nostr.wine or auth.nostr1.com + a personal backup.',
|
||||
},
|
||||
{
|
||||
icon: Lock,
|
||||
name: 'Private Home',
|
||||
count: '1–2',
|
||||
what: 'Drafts, bookmarks, lists & app settings — nobody else reads these.',
|
||||
tip: 'A local relay (Citrine) or an authed personal relay.',
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
name: 'Search',
|
||||
count: '2–3',
|
||||
what: 'Powers tagging & search. Must support NIP-50.',
|
||||
tip: 'relay.nostr.band, nostr.wine, relay.noswhere.com.',
|
||||
},
|
||||
{
|
||||
icon: HardDrive,
|
||||
name: 'Local',
|
||||
count: '1',
|
||||
what: 'On-device cache so the app is fast & works offline.',
|
||||
tip: 'Citrine on Android → ws://localhost:4869.',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideShell kicker="Amethyst — the confusing part" title="Relays come in categories">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="cat in categories"
|
||||
:key="cat.name"
|
||||
class="rounded-xl border border-zinc-700 bg-zinc-900/60 p-5"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<component :is="cat.icon" class="h-6 w-6 text-violet-400" />
|
||||
<span class="rounded-full bg-violet-600/20 px-2 py-0.5 text-xs font-semibold text-violet-300">
|
||||
{{ cat.count }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white">{{ cat.name }}</p>
|
||||
<p class="mt-1 text-sm leading-snug text-zinc-400">{{ cat.what }}</p>
|
||||
<p class="mt-3 text-xs leading-snug text-violet-300/90">→ {{ cat.tip }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-6 text-sm text-zinc-500">
|
||||
Don’t overthink it: set <span class="text-zinc-300">outbox</span>, <span class="text-zinc-300">inbox</span> and
|
||||
<span class="text-zinc-300">DM</span> with ~3 solid relays each. Amethyst suggests the rest from who you follow.
|
||||
</p>
|
||||
</SlideShell>
|
||||
</template>
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import SlideShell from './SlideShell.vue'
|
||||
|
||||
type Role = 'server' | 'recipient' | 'sender'
|
||||
interface Line {
|
||||
key: string
|
||||
value: string
|
||||
kind: 'string' | 'number'
|
||||
comment: string
|
||||
role?: Role
|
||||
tag?: boolean // an entry in the event's `tags` array (vs. a top-level field)
|
||||
}
|
||||
|
||||
const lines: Line[] = [
|
||||
{ key: 'kind', value: '9735', kind: 'number', comment: 'a zap receipt' },
|
||||
{ key: 'pubkey', value: '"9be0…7c"', kind: 'string', comment: 'the zapper service’s key — it signs, not you', role: 'server' },
|
||||
{ key: 'p', value: '"3bf0c6…2a"', kind: 'string', comment: 'who was zapped (recipient)', role: 'recipient', tag: true },
|
||||
{ key: 'e', value: '"0a1b2c…9f"', kind: 'string', comment: 'which note (optional)', tag: true },
|
||||
{ key: 'P', value: '"82f4…d1"', kind: 'string', comment: 'who zapped (optional)', role: 'sender', tag: true },
|
||||
{ key: 'bolt11', value: '"lnbc210n1…"', kind: 'string', comment: 'the invoice that was paid', tag: true },
|
||||
{ key: 'description', value: '"{…9734…}"', kind: 'string', comment: 'your signed request: sender + amount + comment', role: 'sender', tag: true },
|
||||
{ key: 'sig', value: '"e7c9…"', kind: 'string', comment: 'signed by the service key', role: 'server' },
|
||||
]
|
||||
|
||||
const valueClass = { string: 'text-emerald-300', number: 'text-amber-300' }
|
||||
const roleBadge: Record<Role, { label: string; class: string }> = {
|
||||
server: { label: 'server', class: 'bg-violet-500/15 text-violet-300' },
|
||||
recipient: { label: 'zapped', class: 'bg-sky-500/15 text-sky-300' },
|
||||
sender: { label: 'zapper', class: 'bg-rose-500/15 text-rose-300' },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideShell kicker="NIP-57 · kind 9735" title="Anatomy of a zap receipt">
|
||||
<p class="mb-4 text-zinc-400">The event the provider publishes when your zap is paid:</p>
|
||||
|
||||
<div class="flex items-center justify-center gap-3 sm:gap-6">
|
||||
<span class="select-none text-[8rem] font-thin leading-none text-violet-500/80 sm:text-[10rem]">{</span>
|
||||
|
||||
<div class="grid grid-cols-[auto_auto_1fr] items-center gap-x-5 gap-y-1.5 font-mono text-base sm:text-lg">
|
||||
<template v-for="l in lines" :key="l.key">
|
||||
<span class="justify-self-end whitespace-nowrap" :class="l.tag ? 'text-violet-300/90' : 'font-semibold text-violet-300'">
|
||||
<span v-if="l.tag" class="text-zinc-600">tag </span>"{{ l.key }}"<span class="text-zinc-500">:</span>
|
||||
</span>
|
||||
<span class="whitespace-nowrap" :class="valueClass[l.kind]">{{ l.value }}</span>
|
||||
<span class="whitespace-nowrap text-sm text-zinc-600">
|
||||
<span class="text-zinc-500">// </span>{{ l.comment
|
||||
}}<span
|
||||
v-if="l.role"
|
||||
class="ml-1 rounded px-1 text-xs font-semibold"
|
||||
:class="roleBadge[l.role].class"
|
||||
>{{ roleBadge[l.role].label }}</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<span class="select-none text-[8rem] font-thin leading-none text-violet-500/80 sm:text-[10rem]">}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap justify-center gap-x-6 gap-y-1 text-sm">
|
||||
<span><span class="mr-1 inline-block h-2 w-2 rounded-full bg-violet-400" />service signs it</span>
|
||||
<span><span class="mr-1 inline-block h-2 w-2 rounded-full bg-sky-400" /><code>p</code> = who was zapped</span>
|
||||
<span><span class="mr-1 inline-block h-2 w-2 rounded-full bg-rose-400" /><code>description</code> = who zapped (your signed 9734)</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-zinc-500">
|
||||
Clients trust it only if <code class="text-zinc-300">pubkey</code> matches the
|
||||
<code class="text-zinc-300">nostrPubkey</code> the recipient’s provider advertised — that’s what stops fake zaps.
|
||||
</p>
|
||||
</SlideShell>
|
||||
</template>
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Wallet, BadgeCheck, ArrowRight, Zap } from '@lucide/vue'
|
||||
import SlideShell from './SlideShell.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideShell kicker="Lightning + a receipt" title="How a zap works ⚡">
|
||||
<div class="flex flex-col items-stretch gap-4 lg:flex-row lg:items-stretch">
|
||||
<!-- Step 1: plain Lightning payment (not Nostr) -->
|
||||
<div class="flex-1 rounded-xl border border-amber-500/40 bg-amber-950/10 p-6">
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<span class="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-amber-500/20 text-sm font-bold text-amber-300">1</span>
|
||||
<Wallet class="h-6 w-6 text-amber-400" />
|
||||
<span class="text-sm font-semibold uppercase tracking-wider text-amber-400">Plain Lightning · not Nostr</span>
|
||||
</div>
|
||||
<p class="text-white">You pay a Bitcoin <span class="font-semibold">Lightning invoice</span>.</p>
|
||||
<p class="mt-2 text-base text-zinc-400">
|
||||
Their profile lists a Lightning address. Your app fetches an invoice and pays it — instant,
|
||||
cheap, and on its own <em>invisible</em>. This step is 100% Lightning; Nostr isn’t involved yet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ArrowRight class="mx-auto h-6 w-6 rotate-90 flex-none self-center text-zinc-500 lg:rotate-0" />
|
||||
|
||||
<!-- Step 2: the signed receipt (the Nostr part) -->
|
||||
<div class="flex-1 rounded-xl border border-violet-600/50 bg-violet-950/20 p-6">
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<span class="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-violet-500/20 text-sm font-bold text-violet-300">2</span>
|
||||
<BadgeCheck class="h-6 w-6 text-violet-400" />
|
||||
<span class="text-sm font-semibold uppercase tracking-wider text-violet-400">The receipt · the Nostr part</span>
|
||||
</div>
|
||||
<p class="text-white">Their Lightning provider signs a <span class="font-semibold">zap receipt</span>.</p>
|
||||
<p class="mt-2 text-base text-zinc-400">
|
||||
The provider has its <span class="text-zinc-200">own Nostr key</span>. When the invoice clears it
|
||||
signs a <code class="text-zinc-300">kind 9735</code> event — tagged with
|
||||
<span class="text-zinc-200">who was zapped</span> and embedding your signed request (which says who
|
||||
zapped, how much, and any comment) — then posts it to the relays you named.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Takeaway -->
|
||||
<div class="mt-6 flex items-start gap-3 rounded-lg border-l-2 border-violet-500 bg-zinc-900/50 py-3 pl-4 pr-4">
|
||||
<Zap class="mt-0.5 h-5 w-5 flex-none text-violet-400" />
|
||||
<p class="text-zinc-300">
|
||||
Same sats either way — the <span class="font-semibold text-white">public, signed receipt</span> is what turns a
|
||||
private Lightning tip into a visible <em>zap</em>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-sm text-zinc-500">
|
||||
Under the hood (NIP-57): your signed
|
||||
<code class="text-zinc-300">zap request (kind 9734)</code> rides along with the LNURL-pay invoice; the
|
||||
provider advertises <code class="text-zinc-300">allowsNostr</code> + its
|
||||
<code class="text-zinc-300">nostrPubkey</code>, and clients only trust a
|
||||
<code class="text-zinc-300">9735</code> receipt signed by that key. (Next: its anatomy →)
|
||||
</p>
|
||||
</SlideShell>
|
||||
</template>
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
import type { Slide } from './types'
|
||||
|
||||
import TitleSlide from '@/components/slides/TitleSlide.vue'
|
||||
import BulletsSlide from '@/components/slides/BulletsSlide.vue'
|
||||
import KeysSlide from '@/components/slides/KeysSlide.vue'
|
||||
import EventAnatomySlide from '@/components/slides/EventAnatomySlide.vue'
|
||||
import ProtocolCompareSlide from '@/components/slides/ProtocolCompareSlide.vue'
|
||||
import ProtocolTableSlide from '@/components/slides/ProtocolTableSlide.vue'
|
||||
import OutboxSlide from '@/components/slides/OutboxSlide.vue'
|
||||
import RelayCategoriesSlide from '@/components/slides/RelayCategoriesSlide.vue'
|
||||
import RelayAccessSlide from '@/components/slides/RelayAccessSlide.vue'
|
||||
import ZapSlide from '@/components/slides/ZapSlide.vue'
|
||||
import ZapReceiptSlide from '@/components/slides/ZapReceiptSlide.vue'
|
||||
import ResourcesSlide from '@/components/slides/ResourcesSlide.vue'
|
||||
|
||||
/**
|
||||
* Ordered deck. Each entry maps an id (used for the URL hash) to a layout
|
||||
* component + its props. Text slides reuse BulletsSlide; the three hard
|
||||
* concepts (protocol model, outbox, relay categories) get bespoke components.
|
||||
*/
|
||||
export const slides: Slide[] = [
|
||||
{
|
||||
id: 'intro',
|
||||
label: 'Welcome',
|
||||
component: TitleSlide,
|
||||
props: {
|
||||
title: 'Getting on Nostr',
|
||||
subtitle: 'A decentralized social protocol you actually own — in about 10 minutes.',
|
||||
tagline: 'Notes and Other Stuff Transmitted by Relays',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'what',
|
||||
label: 'What is Nostr?',
|
||||
component: BulletsSlide,
|
||||
props: {
|
||||
kicker: 'The 30-second version',
|
||||
title: 'What is Nostr?',
|
||||
bullets: [
|
||||
{ lead: 'A protocol, not an app.', text: 'Like email — many apps, one open network.' },
|
||||
{ lead: 'No signup.', text: 'No email, no phone number, no company approving you.' },
|
||||
{ lead: 'You own your identity.', text: 'A key pair you carry between apps.' },
|
||||
{ lead: 'Censorship-resistant.', text: 'No single server can erase you.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vs-http',
|
||||
label: 'Nostr vs HTTP',
|
||||
component: ProtocolCompareSlide,
|
||||
},
|
||||
{
|
||||
id: 'vs-http-table',
|
||||
label: 'HTTP vs Nostr table',
|
||||
component: ProtocolTableSlide,
|
||||
},
|
||||
{
|
||||
id: 'keys',
|
||||
label: 'Your keys',
|
||||
component: KeysSlide,
|
||||
},
|
||||
{
|
||||
id: 'guard-nsec',
|
||||
label: 'Guard your nsec',
|
||||
component: BulletsSlide,
|
||||
props: {
|
||||
kicker: 'Rule #1',
|
||||
title: 'Protect your nsec',
|
||||
bullets: [
|
||||
{ lead: 'It can’t be reset.', text: 'Lose it and the account is gone forever.' },
|
||||
{ lead: 'It’s total control.', text: 'Anyone with it can post as you and read your DMs.' },
|
||||
{ lead: 'Never paste it into websites.', text: 'Legit apps never ask you to.' },
|
||||
{ lead: 'Use a signer app.', text: 'On Android, Amber holds the key and approves actions for you.' },
|
||||
],
|
||||
footnote: 'Back it up offline — write it on paper or store it in a password manager.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'signing',
|
||||
label: 'Signing',
|
||||
component: BulletsSlide,
|
||||
props: {
|
||||
kicker: 'How you log in',
|
||||
title: 'Every action is signed',
|
||||
bullets: [
|
||||
{ lead: 'No password server.', text: 'You prove who you are with a cryptographic signature.' },
|
||||
{ lead: 'Each post, like & follow', text: 'is signed by your key before it goes out.' },
|
||||
{ lead: 'Anyone can verify it', text: 'came from your npub — math, not trust.' },
|
||||
{ lead: 'A signer (Amber) does this', text: 'so your nsec never touches individual apps.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'event',
|
||||
label: 'Anatomy of an event',
|
||||
component: EventAnatomySlide,
|
||||
},
|
||||
{
|
||||
id: 'amethyst',
|
||||
label: 'Get Amethyst',
|
||||
component: BulletsSlide,
|
||||
props: {
|
||||
kicker: 'Hands-on · Android',
|
||||
title: 'Get started with Amethyst',
|
||||
bullets: [
|
||||
{ lead: '1. Install Amethyst', text: 'from the Play Store, Zap.store, or F-Droid (Obtainium).' },
|
||||
{ lead: '2. Install Amber', text: 'the companion signer — keeps your nsec out of every app.' },
|
||||
{ lead: '3. Create your key', text: 'Amber generates your npub / nsec pair.' },
|
||||
{ lead: '4. Back up the nsec', text: 'before you do anything else.' },
|
||||
],
|
||||
footnote: 'iOS or web instead? Primal, Damus (iOS) and Nos work the same way — concepts carry over.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Your profile',
|
||||
component: BulletsSlide,
|
||||
props: {
|
||||
kicker: 'Make it yours',
|
||||
title: 'Set up your profile',
|
||||
bullets: [
|
||||
{ lead: 'Display name & picture', text: 'so people recognize you.' },
|
||||
{ lead: 'A short bio', text: 'tell folks what you’re into.' },
|
||||
{ lead: 'NIP-05 (optional)', text: 'a name@domain handle that verifies you — like a checkmark you control.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'follow',
|
||||
label: 'Find people',
|
||||
component: BulletsSlide,
|
||||
props: {
|
||||
kicker: 'Fill your feed',
|
||||
title: 'Find people to follow',
|
||||
bullets: [
|
||||
{ lead: 'Search a known npub', text: 'paste a friend’s public key.' },
|
||||
{ lead: 'Follow packs', text: 'Amethyst suggests curated starter lists.' },
|
||||
{ lead: 'nostr.directory & #nostr', text: 'find people who’ve linked their accounts.' },
|
||||
{ lead: 'Follow ~20 people', text: 'a fuller feed makes everything click.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'relays-what',
|
||||
label: 'What are relays?',
|
||||
component: BulletsSlide,
|
||||
props: {
|
||||
kicker: 'The infrastructure',
|
||||
title: 'What is a relay?',
|
||||
bullets: [
|
||||
{ lead: 'A dumb server', text: 'that stores notes and rebroadcasts them to whoever asks.' },
|
||||
{ lead: 'Anyone can run one', text: 'there are thousands; most are free.' },
|
||||
{ lead: 'You connect to several', text: 'for redundancy — if one goes down, your notes live on the others.' },
|
||||
{ lead: 'Relays don’t know each other', text: 'your app is what ties them together.' },
|
||||
],
|
||||
footnote: 'Feed feels slow or empty? It’s almost always your relay choice — not the app.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'outbox',
|
||||
label: 'Outbox model',
|
||||
component: OutboxSlide,
|
||||
},
|
||||
{
|
||||
id: 'relay-categories',
|
||||
label: 'Relay categories',
|
||||
component: RelayCategoriesSlide,
|
||||
},
|
||||
{
|
||||
id: 'relay-access',
|
||||
label: 'Why the sets differ',
|
||||
component: RelayAccessSlide,
|
||||
},
|
||||
{
|
||||
id: 'paid-relays',
|
||||
label: 'Paid vs free',
|
||||
component: BulletsSlide,
|
||||
props: {
|
||||
kicker: 'Choosing relays',
|
||||
title: 'Free, paid & where to look',
|
||||
bullets: [
|
||||
{ lead: 'Most relays are free', text: 'great to start — just add a few big, reliable ones.' },
|
||||
{ lead: 'Open relays double up', text: 'a free, openly-readable/writable relay (nos.lol) can sit in several lists at once.' },
|
||||
{ lead: 'Paid relays stay in their lane', text: 'nostr.wine & co. restrict access — keep them category-specific (e.g. DMs), never your public inbox.' },
|
||||
{ lead: 'Browse & health-check', text: 'nostr.watch shows which relays are up, fast, free vs. paid, and reputable.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'first-actions',
|
||||
label: 'First actions',
|
||||
component: BulletsSlide,
|
||||
props: {
|
||||
kicker: 'You’re on',
|
||||
title: 'Post your first note — and zaps',
|
||||
bullets: [
|
||||
{ lead: 'Just start typing', text: 'hit post; it goes to your outbox relays and out to the world.' },
|
||||
{ lead: 'Reply, like, repost', text: 'same as any social app — but portable.' },
|
||||
{ lead: 'Zaps ⚡', text: 'send real Bitcoin tips over Lightning by tapping the zap icon.' },
|
||||
{ lead: 'Add a wallet later', text: 'connect a Lightning wallet to send and receive zaps.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'zap',
|
||||
label: 'How a zap works',
|
||||
component: ZapSlide,
|
||||
},
|
||||
{
|
||||
id: 'zap-receipt',
|
||||
label: 'Zap receipt anatomy',
|
||||
component: ZapReceiptSlide,
|
||||
},
|
||||
{
|
||||
id: 'recap',
|
||||
label: 'Recap',
|
||||
component: ResourcesSlide,
|
||||
props: {
|
||||
kicker: 'You made it',
|
||||
title: 'Recap & resources',
|
||||
takeaways: [
|
||||
'Your keys are your identity — guard the nsec.',
|
||||
'You publish to relays you choose; followers read from them (outbox).',
|
||||
'Set ~3 outbox, ~3 inbox, ~3 DM relays and you’re sorted.',
|
||||
'No server owns you — move apps and relays freely.',
|
||||
],
|
||||
links: [
|
||||
{ label: 'nostr.how — get started', url: 'https://nostr.how/en/get-started' },
|
||||
{ label: 'Vitor Pamplona — Relay Setup 101', url: 'https://vitor.npub.pro/post/relay-setup/' },
|
||||
{ label: 'nostr.watch — browse relays', url: 'https://nostr.watch' },
|
||||
{ label: 'Amethyst on GitHub', url: 'https://github.com/vitorpamplona/amethyst' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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>
|
||||
}
|
||||
|
|
@ -3,8 +3,8 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
|||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'presentation',
|
||||
component: () => import('@/views/PresentationView.vue'),
|
||||
name: 'home',
|
||||
component: () => import('@/views/HomeView.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
<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