192 lines
7.8 KiB
Vue
192 lines
7.8 KiB
Vue
<script setup lang="ts">
|
|
// Upload wizard: pick/create podcast → choose mp4 → hash + blossom upload →
|
|
// episode details → published (feed URL result).
|
|
import { computed, onMounted, ref } from 'vue';
|
|
import { api } from '../../lib/api';
|
|
import { probeDuration, sha256File, uploadToBlossom } from '../../lib/blossom';
|
|
import { useSettingsStore } from '../../stores/settings';
|
|
import PodcastForm from '../../components/PodcastForm.vue';
|
|
import CopyField from '../../components/CopyField.vue';
|
|
|
|
interface PodcastSummary { id: string; title: string; feed_url: string }
|
|
|
|
const settings = useSettingsStore();
|
|
const step = ref<'podcast' | 'file' | 'details' | 'done'>('podcast');
|
|
const podcasts = ref<PodcastSummary[]>([]);
|
|
const podcast = ref<PodcastSummary | null>(null);
|
|
const creatingNew = ref(false);
|
|
|
|
const file = ref<File | null>(null);
|
|
const phase = ref<'idle' | 'hashing' | 'uploading' | 'registering'>('idle');
|
|
const progress = ref(0);
|
|
const error = ref('');
|
|
|
|
const epTitle = ref('');
|
|
const epDescription = ref('');
|
|
const epPriceSats = ref<number | null>(null);
|
|
const uploaded = ref<{ sha256: string; size: number } | null>(null);
|
|
const durationSecs = ref<number | null>(null);
|
|
const episodeUrl = ref('');
|
|
|
|
const phaseLabel = computed(() => ({
|
|
idle: '',
|
|
hashing: 'Computing sha256…',
|
|
uploading: `Uploading to Blossom… ${(progress.value * 100).toFixed(0)}%`,
|
|
registering: 'Publishing episode…',
|
|
}[phase.value]));
|
|
|
|
onMounted(async () => {
|
|
await settings.ensureLoaded();
|
|
podcasts.value = await api.get<PodcastSummary[]>('/api/podcasts');
|
|
if (podcasts.value.length === 0) creatingNew.value = true;
|
|
});
|
|
|
|
function choosePodcast(p: PodcastSummary) {
|
|
podcast.value = p;
|
|
step.value = 'file';
|
|
}
|
|
|
|
function onPodcastCreated(p: PodcastSummary) {
|
|
podcasts.value.push(p);
|
|
choosePodcast(p);
|
|
}
|
|
|
|
function onFilePicked(e: Event) {
|
|
const f = (e.target as HTMLInputElement).files?.[0];
|
|
if (!f) return;
|
|
if (f.size > 2 * 1024 * 1024 * 1024) {
|
|
error.value = 'File is larger than 2 GB — not supported yet.';
|
|
return;
|
|
}
|
|
error.value = '';
|
|
file.value = f;
|
|
epTitle.value ||= f.name.replace(/\.\w+$/, '');
|
|
step.value = 'details';
|
|
}
|
|
|
|
async function publish() {
|
|
if (!file.value || !podcast.value) return;
|
|
error.value = '';
|
|
try {
|
|
phase.value = 'hashing';
|
|
durationSecs.value = await probeDuration(file.value);
|
|
const sha = await sha256File(file.value);
|
|
|
|
phase.value = 'uploading';
|
|
const blossomUrl = settings.public!.blossomUrl;
|
|
await uploadToBlossom(blossomUrl, file.value, sha, (f) => (progress.value = f));
|
|
uploaded.value = { sha256: sha, size: file.value.size };
|
|
|
|
phase.value = 'registering';
|
|
const episode = await api.post<{ enclosure_url: string }>(
|
|
`/api/podcasts/${podcast.value.id}/episodes`,
|
|
{
|
|
title: epTitle.value,
|
|
description: epDescription.value,
|
|
sha256: sha,
|
|
size: file.value.size,
|
|
mime: file.value.type || 'video/mp4',
|
|
duration_secs: durationSecs.value,
|
|
price_sats: epPriceSats.value || undefined,
|
|
},
|
|
);
|
|
episodeUrl.value = episode.enclosure_url;
|
|
step.value = 'done';
|
|
} catch (err) {
|
|
error.value = (err as Error).message;
|
|
} finally {
|
|
phase.value = 'idle';
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="mx-auto max-w-2xl space-y-6">
|
|
<h1 class="text-2xl font-bold">Upload an episode</h1>
|
|
|
|
<!-- Step 1: podcast -->
|
|
<div v-if="step === 'podcast'" class="card space-y-4">
|
|
<template v-if="podcasts.length && !creatingNew">
|
|
<h2 class="font-semibold">Which podcast is this episode for?</h2>
|
|
<ul class="space-y-2">
|
|
<li v-for="p in podcasts" :key="p.id">
|
|
<button class="btn-secondary w-full !justify-start" @click="choosePodcast(p)">{{ p.title }}</button>
|
|
</li>
|
|
</ul>
|
|
<button class="text-sm text-orange-400 underline" @click="creatingNew = true">+ Start a new podcast</button>
|
|
</template>
|
|
<template v-else>
|
|
<h2 class="font-semibold">{{ podcasts.length ? 'New podcast' : 'First, set up your podcast' }}</h2>
|
|
<PodcastForm @created="onPodcastCreated" />
|
|
<button v-if="podcasts.length" class="text-sm text-white/50 underline" @click="creatingNew = false">
|
|
← Back to existing podcasts
|
|
</button>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Step 2: file -->
|
|
<div v-else-if="step === 'file'" class="card space-y-4">
|
|
<h2 class="font-semibold">Choose the mp4 to publish to “{{ podcast?.title }}”</h2>
|
|
<input type="file" accept="video/mp4,audio/mpeg,audio/mp4" class="input" @change="onFilePicked" />
|
|
<p class="text-xs text-white/30">
|
|
The file is hashed in your browser and uploaded straight to the Blossom server
|
|
({{ settings.public?.blossomUrl }}), signed with your nostr key.
|
|
</p>
|
|
<p v-if="error" class="rounded-lg bg-red-500/20 border border-red-500/40 p-3 text-sm text-red-200">{{ error }}</p>
|
|
</div>
|
|
|
|
<!-- Step 3: details + upload -->
|
|
<div v-else-if="step === 'details'" class="card space-y-4">
|
|
<h2 class="font-semibold">Episode details</h2>
|
|
<p class="text-sm text-white/50">{{ file?.name }} · {{ ((file?.size ?? 0) / 1048576).toFixed(1) }} MB</p>
|
|
<div>
|
|
<label class="label" for="ep-title">Episode title</label>
|
|
<input id="ep-title" v-model="epTitle" class="input" required maxlength="300" />
|
|
</div>
|
|
<div>
|
|
<label class="label" for="ep-desc">Show notes</label>
|
|
<textarea id="ep-desc" v-model="epDescription" class="input" rows="4" placeholder="What happens in this episode?" />
|
|
</div>
|
|
<div>
|
|
<label class="label" for="ep-price">Price in sats (optional — leave blank for a free episode)</label>
|
|
<input id="ep-price" v-model.number="epPriceSats" type="number" min="1" step="1" class="input" placeholder="Free" />
|
|
<p class="text-xs text-white/30">
|
|
Buyers pay this to unlock the download, then get a certified receipt they can
|
|
re-sell the same file with — you get a cut of resales too (see podcast settings).
|
|
</p>
|
|
</div>
|
|
<div v-if="phase !== 'idle'" class="space-y-2">
|
|
<div class="h-2 overflow-hidden rounded-full bg-white/10">
|
|
<div class="h-full bg-orange-400 transition-all" :style="{ width: `${progress * 100}%` }" />
|
|
</div>
|
|
<p class="text-sm text-white/50">{{ phaseLabel }}</p>
|
|
</div>
|
|
<p v-if="error" class="rounded-lg bg-red-500/20 border border-red-500/40 p-3 text-sm text-red-200">{{ error }}</p>
|
|
<div class="flex gap-2">
|
|
<button class="btn-secondary" :disabled="phase !== 'idle'" @click="step = 'file'">Back</button>
|
|
<button class="btn-primary" :disabled="phase !== 'idle' || !epTitle" @click="publish">
|
|
{{ phase === 'idle' ? 'Upload & publish' : 'Working…' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 4: done -->
|
|
<div v-else class="card space-y-4">
|
|
<h2 class="text-lg font-semibold text-green-400">🎉 Episode published!</h2>
|
|
<CopyField label="Your RSS feed — subscribe in any podcast app" :value="podcast!.feed_url" />
|
|
<CopyField label="Direct media URL (on Blossom)" :value="episodeUrl" />
|
|
<p class="text-sm text-white/50">
|
|
Feed includes your lightning payment info (Podcasting 2.0 value block).
|
|
Validate it with
|
|
<a class="text-orange-400 underline" :href="`https://validator.livewire.io/?feed_url=${encodeURIComponent(podcast!.feed_url)}`" target="_blank" rel="noopener">Livewire</a>
|
|
or submit it to
|
|
<a class="text-orange-400 underline" href="https://podcastindex.org/add" target="_blank" rel="noopener">Podcast Index</a>.
|
|
</p>
|
|
<div class="flex gap-2">
|
|
<RouterLink to="/" class="btn-secondary">Home</RouterLink>
|
|
<button class="btn-primary" @click="step = 'file'; file = null">Upload another</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|