ui changes

This commit is contained in:
2026-07-30 13:48:45 +00:00
parent e92ef7fef9
commit a050e2e5f9
14 changed files with 109 additions and 104 deletions
+6 -6
View File
@@ -12,16 +12,16 @@ async function logout() {
</script>
<template>
<div class="min-h-screen bg-slate-50 text-slate-900">
<header class="border-b border-slate-200 bg-white">
<div class="min-h-screen bg-black text-white">
<header class="border-b border-white/15 bg-black/50 backdrop-blur-xl">
<div class="mx-auto flex max-w-4xl items-center justify-between px-4 py-3">
<RouterLink to="/" class="flex items-center gap-2 text-lg font-bold text-puddle-700">
<RouterLink to="/" class="flex items-center gap-2 text-lg font-bold text-white">
<span aria-hidden="true">💧</span> podsteadr
</RouterLink>
<nav v-if="auth.pubkey" class="flex items-center gap-4 text-sm">
<RouterLink to="/" class="hover:text-puddle-600">Home</RouterLink>
<RouterLink to="/earnings" class="hover:text-puddle-600">Earnings</RouterLink>
<RouterLink to="/settings" class="hover:text-puddle-600">Settings</RouterLink>
<RouterLink to="/" class="hover:text-orange-400">Home</RouterLink>
<RouterLink to="/earnings" class="hover:text-orange-400">Earnings</RouterLink>
<RouterLink to="/settings" class="hover:text-orange-400">Settings</RouterLink>
<button class="btn-secondary !px-3 !py-1" @click="logout">
<span class="max-w-[8rem] truncate font-mono text-xs">{{ auth.displayName || auth.pubkey.slice(0, 8) + '…' }}</span>
Logout
+11 -11
View File
@@ -133,14 +133,14 @@ function cancelBuy() {
<template>
<div class="space-y-3">
<p v-if="loading" class="text-sm text-slate-500">Loading sources</p>
<p v-else-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
<p v-if="loading" class="text-sm text-white/50">Loading sources</p>
<p v-else-if="error" class="rounded-lg bg-red-500/20 border border-red-500/40 p-3 text-sm text-red-200">{{ error }}</p>
<template v-else>
<div class="flex gap-3 text-xs text-slate-500">
<div class="flex gap-3 text-xs text-white/50">
<span>Sort by:</span>
<button class="underline" :class="{ 'font-semibold text-slate-800': sortKey === 'price_sats' }" @click="sortKey = 'price_sats'">cheapest</button>
<button class="underline" :class="{ 'font-semibold text-slate-800': sortKey === 'sales_count' }" @click="sortKey = 'sales_count'">reputation</button>
<button class="underline" :class="{ 'font-semibold text-slate-800': sortKey === 'latencyMs' }" @click="sortKey = 'latencyMs'">fastest</button>
<button class="underline" :class="{ 'font-semibold text-white/90': sortKey === 'price_sats' }" @click="sortKey = 'price_sats'">cheapest</button>
<button class="underline" :class="{ 'font-semibold text-white/90': sortKey === 'sales_count' }" @click="sortKey = 'sales_count'">reputation</button>
<button class="underline" :class="{ 'font-semibold text-white/90': sortKey === 'latencyMs' }" @click="sortKey = 'latencyMs'">fastest</button>
</div>
<ul class="space-y-2">
<li v-for="row in sortedRows" :key="`${row.type}-${row.pubkey}`" class="card !p-3">
@@ -148,9 +148,9 @@ function cancelBuy() {
<div class="min-w-0">
<p class="font-medium">
{{ row.type === 'producer' ? 'Producer' : 'Reseller' }}
<span class="font-mono text-xs text-slate-400">{{ row.pubkey.slice(0, 12) }}</span>
<span class="font-mono text-xs text-white/30">{{ row.pubkey.slice(0, 12) }}</span>
</p>
<p class="text-xs text-slate-500">
<p class="text-xs text-white/50">
{{ row.price_sats }} sats
<span v-if="row.type === 'reseller'">· {{ row.sales_count }} sales</span>
· {{ row.hostKind }}
@@ -162,10 +162,10 @@ function cancelBuy() {
</button>
</div>
<div v-if="buyingPubkey === row.pubkey" class="mt-3 space-y-2 border-t border-slate-100 pt-3">
<div v-if="buyingPubkey === row.pubkey" class="mt-3 space-y-2 border-t border-white/10 pt-3">
<template v-if="!purchased">
<CopyField label="Pay this invoice with any Lightning wallet" :value="invoice" />
<p v-if="confirmError" class="text-sm text-red-700">{{ confirmError }}</p>
<p v-if="confirmError" class="text-sm text-red-400">{{ confirmError }}</p>
<div class="flex gap-2">
<button class="btn-secondary" @click="cancelBuy">Cancel</button>
<button class="btn-primary" :disabled="confirming" @click="checkPaid">
@@ -173,7 +173,7 @@ function cancelBuy() {
</button>
</div>
</template>
<p v-else class="text-sm font-medium text-green-700">🎉 Purchased you now have access.</p>
<p v-else class="text-sm font-medium text-green-400">🎉 Purchased you now have access.</p>
</div>
</li>
</ul>
+4 -4
View File
@@ -79,7 +79,7 @@ async function submit() {
<div>
<label class="label" for="pc-ln">
Lightning address for listener payments
<span class="font-normal text-slate-400">(value-for-value; optional)</span>
<span class="font-normal text-white/30">(value-for-value; optional)</span>
</label>
<input
id="pc-ln"
@@ -88,12 +88,12 @@ async function submit() {
placeholder="you@getalby.com"
pattern="[\w.+-]+@[\w.-]+"
/>
<p class="mt-1 text-xs text-slate-400">
<p class="mt-1 text-xs text-white/30">
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>
<label class="label" for="pc-img">Cover image URL <span class="font-normal text-white/30">(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">
@@ -101,7 +101,7 @@ async function submit() {
Contains explicit content
</label>
<p v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</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>
<button class="btn-primary" type="submit" :disabled="busy || !form.title">
{{ busy ? 'Creating…' : 'Create podcast' }}
</button>
+17 -7
View File
@@ -2,26 +2,36 @@
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
body {
background: #000;
color: white;
min-height: 100vh;
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50;
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 font-medium transition-all disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-primary {
@apply btn bg-puddle-600 text-white hover:bg-puddle-700;
@apply btn border border-white/15 bg-black/60 text-white shadow-lg shadow-black/40 backdrop-blur-xl hover:-translate-y-0.5 hover:bg-black/40 active:translate-y-0;
}
.btn-secondary {
@apply btn border border-slate-300 bg-white text-slate-700 hover:bg-slate-50;
@apply btn border border-white/15 bg-white/5 text-white/80 hover:bg-white/10;
}
.btn-danger {
@apply btn bg-red-600 text-white hover:bg-red-700;
@apply btn border border-red-500/40 bg-red-500/20 text-red-200 hover:bg-red-500/30;
}
.input {
@apply w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 placeholder-slate-400 focus:border-puddle-500 focus:outline-none focus:ring-1 focus:ring-puddle-500;
@apply w-full rounded-lg border border-white/15 bg-black/30 px-3 py-2 text-white placeholder-white/30 focus:border-orange-400/60 focus:outline-none focus:ring-1 focus:ring-orange-400/40;
}
.label {
@apply mb-1 block text-sm font-medium text-slate-700;
@apply mb-1 block text-sm font-medium text-white/70;
}
.card {
@apply rounded-xl border border-slate-200 bg-white p-6 shadow-sm;
@apply rounded-2xl border border-white/15 bg-black/50 p-6 shadow-lg shadow-black/40 backdrop-blur-xl;
}
}
+7 -7
View File
@@ -49,20 +49,20 @@ async function withdraw() {
<template>
<div class="mx-auto max-w-2xl space-y-6">
<h1 class="text-2xl font-bold">Earnings</h1>
<p class="text-sm text-slate-500">
<p class="text-sm text-white/50">
Sats from selling episodes directly, plus your producer cut whenever a certified
reseller resells one of your episodes.
</p>
<div v-if="loading" class="text-sm text-slate-500">Loading</div>
<div v-if="loading" class="text-sm text-white/50">Loading</div>
<template v-else>
<div class="card space-y-4">
<p class="text-3xl font-bold">{{ unwithdrawnSats }} <span class="text-base font-normal text-slate-500">sats available</span></p>
<p class="text-3xl font-bold">{{ unwithdrawnSats }} <span class="text-base font-normal text-white/50">sats available</span></p>
<div v-if="unwithdrawnSats > 0" class="space-y-2">
<label class="label" for="withdraw-lud16">Lightning address to withdraw to</label>
<input id="withdraw-lud16" v-model="lud16" class="input" placeholder="you@getalby.com" pattern="[\w.+-]+@[\w.-]+" />
<p v-if="error" class="text-sm text-red-700">{{ error }}</p>
<p v-if="result" class="text-sm font-medium text-green-700">Paid out {{ result.paid_sats }} sats!</p>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<p v-if="result" class="text-sm font-medium text-green-400">Paid out {{ result.paid_sats }} sats!</p>
<button class="btn-primary" :disabled="withdrawing || !lud16" @click="withdraw">
{{ withdrawing ? 'Paying out' : 'Withdraw' }}
</button>
@@ -70,13 +70,13 @@ async function withdraw() {
</div>
<section v-if="earnings.length">
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">History</h3>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-white/50">History</h3>
<ul class="space-y-2">
<li v-for="e in earnings" :key="e.id" class="card flex items-center justify-between !p-4">
<span class="font-medium">{{ e.amount_sats }} sats</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold"
:class="e.withdrawn_at ? 'bg-slate-100 text-slate-600' : 'bg-green-100 text-green-700'"
:class="e.withdrawn_at ? 'bg-white/10 text-white/60' : 'bg-green-500/20 text-green-300'"
>{{ e.withdrawn_at ? 'withdrawn' : 'available' }}</span>
</li>
</ul>
+10 -10
View File
@@ -85,26 +85,26 @@ async function becomeReseller() {
<template>
<div class="mx-auto max-w-2xl space-y-6">
<p v-if="loading" class="text-sm text-slate-500">Loading</p>
<p v-else-if="notFoundOrFree" class="rounded-lg bg-amber-50 p-3 text-sm text-amber-800">
<p v-if="loading" class="text-sm text-white/50">Loading</p>
<p v-else-if="notFoundOrFree" class="rounded-lg bg-amber-500/20 border border-amber-500/40 p-3 text-sm text-amber-200">
This episode isn't available for sale (it may be free, or no longer exists).
</p>
<template v-else>
<div>
<p class="text-sm text-slate-500">{{ podcastTitle }}</p>
<p class="text-sm text-white/50">{{ podcastTitle }}</p>
<h1 class="text-2xl font-bold">{{ title }}</h1>
<p class="mt-2 whitespace-pre-wrap text-slate-600">{{ description }}</p>
<p class="mt-2 whitespace-pre-wrap text-white/60">{{ description }}</p>
</div>
<div v-if="checkingAccess" class="text-sm text-slate-500">Checking access</div>
<div v-if="checkingAccess" class="text-sm text-white/50">Checking access</div>
<div v-else-if="downloadUrl" class="card space-y-4">
<h2 class="font-semibold text-green-700">🔓 You have access to this episode</h2>
<h2 class="font-semibold text-green-400">🔓 You have access to this episode</h2>
<a :href="downloadUrl" class="btn-primary inline-block" target="_blank" rel="noopener">Download</a>
<div class="border-t border-slate-100 pt-4">
<div class="border-t border-white/10 pt-4">
<h3 class="font-semibold">List your copy for resale</h3>
<p class="mb-2 text-xs text-slate-400">
<p class="mb-2 text-xs text-white/30">
Point at a Blossom-compatible host serving this same file the producer still gets
a cut of anything sold through you.
</p>
@@ -112,13 +112,13 @@ async function becomeReseller() {
<div class="space-y-2">
<input v-model="resellUrl" class="input" type="url" placeholder="https://your-blossom-host.example" />
<input v-model.number="resellPrice" class="input" type="number" min="1" placeholder="Your asking price (sats)" />
<p v-if="resellError" class="text-sm text-red-700">{{ resellError }}</p>
<p v-if="resellError" class="text-sm text-red-400">{{ resellError }}</p>
<button class="btn-secondary" :disabled="resellBusy || !resellUrl || !resellPrice" @click="becomeReseller">
{{ resellBusy ? 'Certifying' : 'List as reseller' }}
</button>
</div>
</template>
<p v-else class="text-sm font-medium text-green-700">You're now a certified reseller for this episode.</p>
<p v-else class="text-sm font-medium text-green-400">You're now a certified reseller for this episode.</p>
</div>
</div>
+7 -7
View File
@@ -30,21 +30,21 @@ onMounted(async () => {
<RouterLink to="/live" class="card block transition-shadow hover:shadow-md">
<div class="mb-2 text-3xl" aria-hidden="true">🔴</div>
<h2 class="text-lg font-semibold">Go live</h2>
<p class="text-sm text-slate-500">
<p class="text-sm text-white/50">
Stream from OBS or your browser. Announced on nostr, watchable anywhere via HLS.
</p>
</RouterLink>
<RouterLink to="/upload" class="card block transition-shadow hover:shadow-md">
<div class="mb-2 text-3xl" aria-hidden="true">🎙</div>
<h2 class="text-lg font-semibold">Upload an episode</h2>
<p class="text-sm text-slate-500">
<p class="text-sm text-white/50">
Publish an mp4 to your own RSS feed with lightning payment info, stored on Blossom.
</p>
</RouterLink>
</div>
<section v-if="streams.length">
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Your streams</h3>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-white/50">Your streams</h3>
<ul class="space-y-2">
<li v-for="s in streams" :key="s.id">
<RouterLink :to="`/streams/${s.id}`" class="card flex items-center justify-between !p-4 hover:shadow-md">
@@ -52,8 +52,8 @@ onMounted(async () => {
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold"
:class="{
'bg-red-100 text-red-700': s.status === 'live',
'bg-slate-100 text-slate-600': s.status !== 'live',
'bg-red-500/20 text-red-300': s.status === 'live',
'bg-white/10 text-white/60': s.status !== 'live',
}"
>{{ s.status === 'live' ? '● LIVE' : s.status }}</span>
</RouterLink>
@@ -62,11 +62,11 @@ onMounted(async () => {
</section>
<section v-if="podcasts.length">
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Your podcasts</h3>
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-white/50">Your podcasts</h3>
<ul class="space-y-2">
<li v-for="p in podcasts" :key="p.id" class="card flex items-center justify-between !p-4">
<span class="font-medium">{{ p.title }}</span>
<a :href="p.feed_url" target="_blank" rel="noopener" class="text-sm text-puddle-600 underline">RSS feed</a>
<a :href="p.feed_url" target="_blank" rel="noopener" class="text-sm text-orange-400 underline">RSS feed</a>
</li>
</ul>
</section>
+6 -6
View File
@@ -28,27 +28,27 @@ async function login() {
<div class="card text-center">
<div class="mb-2 text-4xl" aria-hidden="true">💧</div>
<h1 class="mb-1 text-2xl font-bold">podsteadr</h1>
<p class="mb-6 text-slate-500">Publish podcasts and go live on nostr, from your own server.</p>
<p class="mb-6 text-white/50">Publish podcasts and go live on nostr, from your own server.</p>
<template v-if="hasNip07()">
<button class="btn-primary w-full" :disabled="busy" @click="login">
{{ busy ? 'Waiting for your extension' : 'Log in with nostr' }}
</button>
<p class="mt-3 text-xs text-slate-400">
<p class="mt-3 text-xs text-white/30">
Your extension signs a login request (NIP-98). No password, no email.
</p>
</template>
<template v-else>
<p class="mb-3 rounded-lg bg-amber-50 p-3 text-sm text-amber-800">
<p class="mb-3 rounded-lg bg-amber-500/20 border border-amber-500/40 p-3 text-sm text-amber-200">
No nostr extension detected. Install a NIP-07 signer, then reload this page.
</p>
<div class="flex justify-center gap-3 text-sm">
<a class="text-puddle-600 underline" href="https://getalby.com" target="_blank" rel="noopener">Alby</a>
<a class="text-puddle-600 underline" href="https://github.com/fiatjaf/nos2x" target="_blank" rel="noopener">nos2x</a>
<a class="text-orange-400 underline" href="https://getalby.com" target="_blank" rel="noopener">Alby</a>
<a class="text-orange-400 underline" href="https://github.com/fiatjaf/nos2x" target="_blank" rel="noopener">nos2x</a>
</div>
</template>
<p v-if="error" class="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
<p v-if="error" class="mt-4 rounded-lg bg-red-500/20 border border-red-500/40 p-3 text-sm text-red-200">{{ error }}</p>
</div>
</div>
</template>
+6 -6
View File
@@ -45,7 +45,7 @@ async function save() {
<div>
<label class="label" for="set-blossom">Blossom server URL</label>
<input id="set-blossom" v-model="form.blossom_url" class="input" type="url" required />
<p class="mt-1 text-xs text-slate-400">
<p class="mt-1 text-xs text-white/30">
Where uploaded media is stored. The bundled server is the default; point this at any
external Blossom server to store media there instead. Note: publishing stream
recordings uploads with the <em>server's</em> nostr key an external server must
@@ -55,18 +55,18 @@ async function save() {
<div>
<label class="label" for="set-relays">Nostr relays (one per line)</label>
<textarea id="set-relays" v-model="form.relays" class="input font-mono text-sm" rows="4" />
<p class="mt-1 text-xs text-slate-400">Live streams (NIP-53) are announced to these relays.</p>
<p class="mt-1 text-xs text-white/30">Live streams (NIP-53) are announced to these relays.</p>
</div>
<div>
<label class="label" for="set-public">Public URL of this server</label>
<input id="set-public" v-model="form.public_url" class="input" type="url" required />
<p class="mt-1 text-xs text-slate-400">Used in RSS feed links. Must be reachable by podcast apps.</p>
<p class="mt-1 text-xs text-white/30">Used in RSS feed links. Must be reachable by podcast apps.</p>
</div>
<p v-if="!auth.isAdmin" class="rounded-lg bg-amber-50 p-3 text-sm text-amber-800">
<p v-if="!auth.isAdmin" class="rounded-lg bg-amber-500/20 border border-amber-500/40 p-3 text-sm text-amber-200">
Only the admin (first account to log in) can change settings.
</p>
<p v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
<p v-if="saved" class="rounded-lg bg-green-50 p-3 text-sm text-green-700">Saved.</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>
<p v-if="saved" class="rounded-lg bg-green-500/20 border border-green-500/40 p-3 text-sm text-green-200">Saved.</p>
<button class="btn-primary" type="submit" :disabled="!auth.isAdmin">Save</button>
</form>
</div>
+9 -9
View File
@@ -128,11 +128,11 @@ async function publishRecording(file: string) {
<h1 class="text-2xl font-bold">{{ stream.title }}</h1>
<span
class="rounded-full px-3 py-1 text-sm font-semibold"
:class="isLive ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-600'"
:class="isLive ? 'bg-red-500/20 text-red-300' : 'bg-white/10 text-white/60'"
>{{ isLive ? '● LIVE' : stream.status }}</span>
</div>
<p v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</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>
<!-- Live preview -->
<div v-if="isLive" class="card space-y-3">
@@ -152,11 +152,11 @@ async function publishRecording(file: string) {
:value="streamKey"
secret
/>
<div v-else class="rounded-lg bg-amber-50 p-3 text-sm text-amber-800">
<div v-else class="rounded-lg bg-amber-500/20 border border-amber-500/40 p-3 text-sm text-amber-200">
The stream key is only shown right after creation.
<button class="underline" @click="rotateKey">Generate a new key</button> if you lost it.
</div>
<p class="text-xs text-slate-400">
<p class="text-xs text-white/30">
In OBS: Settings Stream Service Custom, paste Server and Stream Key, then Start Streaming.
</p>
</div>
@@ -173,10 +173,10 @@ async function publishRecording(file: string) {
</button>
</div>
<div v-else class="flex items-center gap-3">
<span class="text-sm font-medium text-red-700">Publishing from this browser</span>
<span class="text-sm font-medium text-red-400">Publishing from this browser</span>
<button class="btn-danger" @click="stopBrowserPublish">Stop</button>
</div>
<p v-if="!whipBearer" class="text-xs text-slate-400">
<p v-if="!whipBearer" class="text-xs text-white/30">
Browser streaming needs the key from this session rotate the key above to enable it.
</p>
</div>
@@ -191,8 +191,8 @@ async function publishRecording(file: string) {
<option v-for="p in podcasts" :key="p.id" :value="p.id">{{ p.title }}</option>
</select>
</div>
<p v-else class="text-sm text-slate-500">
Create a podcast (via <RouterLink class="text-puddle-600 underline" to="/upload">Upload an episode</RouterLink>)
<p v-else class="text-sm text-white/50">
Create a podcast (via <RouterLink class="text-orange-400 underline" to="/upload">Upload an episode</RouterLink>)
to publish recordings as episodes.
</p>
<ul class="space-y-2">
@@ -207,7 +207,7 @@ async function publishRecording(file: string) {
</button>
</li>
</ul>
<p v-if="publishedFeed" class="rounded-lg bg-green-50 p-3 text-sm text-green-700">
<p v-if="publishedFeed" class="rounded-lg bg-green-500/20 border border-green-500/40 p-3 text-sm text-green-200">
Published! Feed: <a class="underline" :href="publishedFeed" target="_blank" rel="noopener">{{ publishedFeed }}</a>
</p>
</div>
+14 -14
View File
@@ -113,12 +113,12 @@ async function publish() {
<button class="btn-secondary w-full !justify-start" @click="choosePodcast(p)">{{ p.title }}</button>
</li>
</ul>
<button class="text-sm text-puddle-600 underline" @click="creatingNew = true">+ Start a new podcast</button>
<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-slate-500 underline" @click="creatingNew = false">
<button v-if="podcasts.length" class="text-sm text-white/50 underline" @click="creatingNew = false">
Back to existing podcasts
</button>
</template>
@@ -128,17 +128,17 @@ async function publish() {
<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-slate-400">
<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-50 p-3 text-sm text-red-700">{{ error }}</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-slate-500">{{ file?.name }} · {{ ((file?.size ?? 0) / 1048576).toFixed(1) }} MB</p>
<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" />
@@ -150,18 +150,18 @@ async function publish() {
<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-slate-400">
<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-slate-200">
<div class="h-full bg-puddle-500 transition-all" :style="{ width: `${progress * 100}%` }" />
<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-slate-500">{{ phaseLabel }}</p>
<p class="text-sm text-white/50">{{ phaseLabel }}</p>
</div>
<p v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</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 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">
@@ -172,15 +172,15 @@ async function publish() {
<!-- Step 4: done -->
<div v-else class="card space-y-4">
<h2 class="text-lg font-semibold text-green-700">🎉 Episode published!</h2>
<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-slate-500">
<p class="text-sm text-white/50">
Feed includes your lightning payment info (Podcasting 2.0 value block).
Validate it with
<a class="text-puddle-600 underline" :href="`https://validator.livewire.io/?feed_url=${encodeURIComponent(podcast!.feed_url)}`" target="_blank" rel="noopener">Livewire</a>
<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-puddle-600 underline" href="https://podcastindex.org/add" target="_blank" rel="noopener">Podcast Index</a>.
<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>
+4 -4
View File
@@ -50,19 +50,19 @@ async function create() {
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="label" for="st-image">Preview image URL <span class="font-normal text-slate-400">(optional)</span></label>
<label class="label" for="st-image">Preview image URL <span class="font-normal text-white/30">(optional)</span></label>
<input id="st-image" v-model="form.image_url" class="input" type="url" placeholder="https://…/preview.jpg" />
</div>
<div>
<label class="label" for="st-tags">Hashtags <span class="font-normal text-slate-400">(comma separated)</span></label>
<label class="label" for="st-tags">Hashtags <span class="font-normal text-white/30">(comma separated)</span></label>
<input id="st-tags" v-model="form.hashtags" class="input" placeholder="music, coding" />
</div>
</div>
<p class="text-xs text-slate-400">
<p class="text-xs text-white/30">
Creating the stream announces it on nostr (NIP-53). Viewers on zap.stream and other
nostr clients will see it go live automatically.
</p>
<p v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</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>
<button class="btn-primary" type="submit" :disabled="busy || !form.title">
{{ busy ? 'Creating…' : 'Create stream' }}
</button>
+1 -12
View File
@@ -2,18 +2,7 @@
export default {
content: ['./index.html', './src/**/*.{vue,ts}'],
theme: {
extend: {
colors: {
puddle: {
50: '#eef7ff',
100: '#d9edff',
500: '#2e90fa',
600: '#1570cd',
700: '#0f5aa8',
900: '#0b3866',
},
},
},
extend: {},
},
plugins: [],
};
+7 -1
View File
@@ -41,9 +41,15 @@ export class SettingsService {
* reachable from inside the container — use the compose-network URL for it.
*/
blossomServerSideUrl(publicBlossomUrl: string): string {
// Compare against the *current effective* own-instance URL (runtime override, if any,
// else the env default) rather than just the static default — otherwise an admin who
// customizes blossom_url at runtime (e.g. to match a Tailscale IP / alternate hostname)
// permanently loses the internal-network shortcut, since their own bundled instance's
// public URL no longer matches the hardcoded default it's compared against.
const ownPublicUrl = this.get('blossom_url') ?? this.config.BLOSSOM_URL_DEFAULT;
if (
this.config.BLOSSOM_URL_INTERNAL &&
publicBlossomUrl.replace(/\/+$/, '') === this.config.BLOSSOM_URL_DEFAULT.replace(/\/+$/, '')
publicBlossomUrl.replace(/\/+$/, '') === ownPublicUrl.replace(/\/+$/, '')
) {
return this.config.BLOSSOM_URL_INTERNAL;
}