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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user