feat(frontend): Vue 3 wizard UI (nostr login, episode upload, go-live)

- NIP-07 login → NIP-98 signed request → session cookie
- episode wizard: pick/create podcast (incl. lightning address question),
  browser-side sha256 + BUD-02 blossom upload with progress, feed result page
- live wizard + stream dashboard: OBS server/key (shown once, rotatable),
  browser WHIP publishing (camera or screen), hls.js live preview,
  publish-recording-as-episode flow
- settings view: external blossom URL, relay list, public URL (admin only)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 19:06:43 +00:00
co-authored by Claude Fable 5
parent 594a9a8783
commit 5216c6451e
26 changed files with 4144 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{ label: string; value: string; secret?: boolean }>();
const copied = ref(false);
const revealed = ref(false);
async function copy() {
await navigator.clipboard.writeText(props.value);
copied.value = true;
setTimeout(() => (copied.value = false), 1500);
}
</script>
<template>
<div>
<span class="label">{{ label }}</span>
<div class="flex gap-2">
<input
class="input flex-1 font-mono text-sm"
:type="secret && !revealed ? 'password' : 'text'"
:value="value"
readonly
@focus="($event.target as HTMLInputElement).select()"
/>
<button v-if="secret" class="btn-secondary" type="button" @click="revealed = !revealed">
{{ revealed ? 'Hide' : 'Show' }}
</button>
<button class="btn-secondary" type="button" @click="copy">{{ copied ? 'Copied!' : 'Copy' }}</button>
</div>
</div>
</template>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import Hls from 'hls.js';
const props = defineProps<{ src: string; muted?: boolean }>();
const video = ref<HTMLVideoElement>();
let hls: Hls | null = null;
function attach() {
if (!video.value) return;
detach();
if (Hls.isSupported()) {
hls = new Hls({ liveSyncDurationCount: 2 });
hls.loadSource(props.src);
hls.attachMedia(video.value);
} else if (video.value.canPlayType('application/vnd.apple.mpegurl')) {
video.value.src = props.src; // Safari native HLS
}
}
function detach() {
hls?.destroy();
hls = null;
}
onMounted(attach);
watch(() => props.src, attach);
onBeforeUnmount(detach);
</script>
<template>
<video
ref="video"
class="aspect-video w-full rounded-lg bg-black"
controls
autoplay
playsinline
:muted="muted"
/>
</template>
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
// Wizard questions for creating (or editing) a podcast, including the
// lightning address used for the RSS value-for-value block.
import { reactive, ref } from 'vue';
import { api } from '../lib/api';
export interface PodcastPayload {
title: string;
description: string;
author: string;
image_url?: string | null;
language: string;
category: string;
explicit: boolean;
lightning_address?: string | null;
}
const emit = defineEmits<{ created: [podcast: { id: string; title: string; feed_url: string }] }>();
const form = reactive<PodcastPayload>({
title: '',
description: '',
author: '',
image_url: '',
language: 'en',
category: 'Technology',
explicit: false,
lightning_address: '',
});
const error = ref('');
const busy = ref(false);
const categories = [
'Arts', 'Business', 'Comedy', 'Education', 'Fiction', 'Government', 'History',
'Health & Fitness', 'Kids & Family', 'Leisure', 'Music', 'News', 'Religion & Spirituality',
'Science', 'Society & Culture', 'Sports', 'Technology', 'True Crime', 'TV & Film',
];
async function submit() {
error.value = '';
busy.value = true;
try {
const created = await api.post<{ id: string; title: string; feed_url: string }>('/api/podcasts', {
...form,
image_url: form.image_url || null,
lightning_address: form.lightning_address || null,
});
emit('created', created);
} catch (err) {
error.value = (err as Error).message;
} finally {
busy.value = false;
}
}
</script>
<template>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="label" for="pc-title">What is your podcast called?</label>
<input id="pc-title" v-model="form.title" class="input" required maxlength="200" placeholder="My Great Show" />
</div>
<div>
<label class="label" for="pc-desc">Describe it in a sentence or two</label>
<textarea id="pc-desc" v-model="form.description" class="input" rows="3" placeholder="A show about…" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="label" for="pc-author">Who is the author?</label>
<input id="pc-author" v-model="form.author" class="input" placeholder="Your name" />
</div>
<div>
<label class="label" for="pc-cat">Category</label>
<select id="pc-cat" v-model="form.category" class="input">
<option v-for="c in categories" :key="c">{{ c }}</option>
</select>
</div>
</div>
<div>
<label class="label" for="pc-ln">
Lightning address for listener payments
<span class="font-normal text-slate-400">(value-for-value; optional)</span>
</label>
<input
id="pc-ln"
v-model="form.lightning_address"
class="input"
placeholder="you@getalby.com"
pattern="[\w.+-]+@[\w.-]+"
/>
<p class="mt-1 text-xs text-slate-400">
Podcast apps like Fountain and Podverse can stream sats to this address while people listen.
</p>
</div>
<div>
<label class="label" for="pc-img">Cover image URL <span class="font-normal text-slate-400">(optional)</span></label>
<input id="pc-img" v-model="form.image_url" class="input" type="url" placeholder="https://…/cover.jpg" />
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.explicit" type="checkbox" class="rounded" />
Contains explicit content
</label>
<p v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
<button class="btn-primary" type="submit" :disabled="busy || !form.title">
{{ busy ? 'Creating…' : 'Create podcast' }}
</button>
</form>
</template>