ui changes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user