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
+37
View File
@@ -0,0 +1,37 @@
// Thin fetch wrapper for the podpuddle API (session cookie auth).
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(method: string, path: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
const res = await fetch(path, {
method,
credentials: 'same-origin',
headers: {
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
let message = res.statusText;
try {
message = ((await res.json()) as { error?: string }).error ?? message;
} catch {
/* keep statusText */
}
throw new ApiError(res.status, message);
}
return (await res.json()) as T;
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
request<T>('POST', path, body, headers),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
};
+76
View File
@@ -0,0 +1,76 @@
// Browser-direct Blossom (BUD-02) upload: hash locally, sign the kind-24242
// authorization with the user's NIP-07 extension, PUT straight to the blossom
// server. The file never transits podpuddle.
import { nip07 } from './nip07';
export async function sha256File(file: File, onProgress?: (frac: number) => void): Promise<string> {
// crypto.subtle needs the whole buffer; fine for podcast-sized mp4s (~2 GB cap).
const buf = await file.arrayBuffer();
onProgress?.(0.5);
const digest = await crypto.subtle.digest('SHA-256', buf);
onProgress?.(1);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
export interface BlossomUpload {
sha256: string;
size: number;
url: string;
}
export async function uploadToBlossom(
blossomUrl: string,
file: File,
sha256: string,
onProgress?: (frac: number) => void,
): Promise<BlossomUpload> {
const now = Math.floor(Date.now() / 1000);
const auth = await nip07().signEvent({
kind: 24242,
created_at: now,
content: `Upload ${file.name}`,
tags: [
['t', 'upload'],
['x', sha256],
['expiration', String(now + 600)],
],
});
const base = blossomUrl.replace(/\/+$/, '');
// XHR instead of fetch for upload progress events.
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', `${base}/upload`);
xhr.setRequestHeader('authorization', `Nostr ${btoa(JSON.stringify(auth))}`);
xhr.setRequestHeader('content-type', file.type || 'video/mp4');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress?.(e.loaded / e.total);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`blossom upload failed: ${xhr.status} ${xhr.responseText.slice(0, 200)}`));
};
xhr.onerror = () => reject(new Error('blossom upload failed: network error'));
xhr.send(file);
});
return { sha256, size: file.size, url: `${base}/${sha256}` };
}
/** Read the media duration (seconds) from a local file, for itunes:duration. */
export function probeDuration(file: File): Promise<number | null> {
return new Promise((resolve) => {
const el = document.createElement('video');
el.preload = 'metadata';
el.onloadedmetadata = () => {
URL.revokeObjectURL(el.src);
resolve(Number.isFinite(el.duration) ? Math.round(el.duration) : null);
};
el.onerror = () => {
URL.revokeObjectURL(el.src);
resolve(null);
};
el.src = URL.createObjectURL(file);
});
}
+48
View File
@@ -0,0 +1,48 @@
// NIP-07 browser extension bridge + NIP-98 header construction.
export interface UnsignedEvent {
kind: number;
created_at: number;
content: string;
tags: string[][];
}
export interface SignedEvent extends UnsignedEvent {
id: string;
pubkey: string;
sig: string;
}
interface Nip07Provider {
getPublicKey(): Promise<string>;
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
}
declare global {
interface Window {
nostr?: Nip07Provider;
}
}
export function hasNip07(): boolean {
return typeof window !== 'undefined' && !!window.nostr;
}
export function nip07(): Nip07Provider {
if (!window.nostr) throw new Error('No NIP-07 nostr extension found');
return window.nostr;
}
/** Sign a NIP-98 (kind 27235) event for the given request and return the Authorization header value. */
export async function buildNip98Header(url: string, method: string): Promise<string> {
const event = await nip07().signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [
['u', url],
['method', method],
],
});
return `Nostr ${btoa(JSON.stringify(event))}`;
}
+62
View File
@@ -0,0 +1,62 @@
// Minimal WHIP (WebRTC-HTTP Ingestion Protocol) publisher for MediaMTX.
export interface WhipSession {
pc: RTCPeerConnection;
stop: () => Promise<void>;
}
export async function publishWhip(
whipUrl: string,
bearer: string,
stream: MediaStream,
): Promise<WhipSession> {
const pc = new RTCPeerConnection();
for (const track of stream.getTracks()) {
pc.addTransceiver(track, { direction: 'sendonly' });
}
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitIceComplete(pc);
const res = await fetch(whipUrl, {
method: 'POST',
headers: {
'content-type': 'application/sdp',
authorization: `Bearer ${bearer}`,
},
body: pc.localDescription!.sdp,
});
if (!res.ok) {
pc.close();
throw new Error(`WHIP publish failed: ${res.status} ${await res.text().catch(() => '')}`);
}
const location = res.headers.get('location');
const answer = await res.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
return {
pc,
stop: async () => {
pc.close();
for (const track of stream.getTracks()) track.stop();
if (location) {
const url = new URL(location, whipUrl).toString();
await fetch(url, { method: 'DELETE', headers: { authorization: `Bearer ${bearer}` } }).catch(() => {});
}
},
};
}
function waitIceComplete(pc: RTCPeerConnection): Promise<void> {
if (pc.iceGatheringState === 'complete') return Promise.resolve();
return new Promise((resolve) => {
const timeout = setTimeout(resolve, 2000); // trickle fallback: send what we have
pc.addEventListener('icegatheringstatechange', () => {
if (pc.iceGatheringState === 'complete') {
clearTimeout(timeout);
resolve();
}
});
});
}