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[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'presentation',
|
name: 'home',
|
||||||
component: () => import('@/views/PresentationView.vue'),
|
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