mirror of
https://github.com/hoornet/vega.git
synced 2026-06-30 14:08:34 -07:00
Add V4V sidebar section with dashboard, settings, and history
- New V4V store with budget tracking, cap enforcement, and history - Dashboard: live streaming status, per-episode + weekly budget bars, stats - Settings: auto-enable toggle (guarded by caps), per-episode cap, weekly budget, default rate, info icons - History: expandable list of past V4V episodes with recipient breakdowns - Budget enforcement: streaming stops with toast when cap is hit - Auto-streaming: starts automatically for V4V episodes when enabled - Cap reached state: dashboard shows red card, ticker shows "(capped)" - V4VIndicator: slimmed popup, AUTO badge, "open v4v" nav link - Fix duplicate history entries on cap stop
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { usePodcastStore } from "../../stores/podcast";
|
||||
import { useV4VStore } from "../../stores/v4v";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { startStreaming, stopStreaming, boost } from "../../lib/podcast/v4v";
|
||||
|
||||
const RATE_OPTIONS = [5, 10, 21, 50, 100];
|
||||
const NWC_KEY = "wrystr_nwc_uri";
|
||||
|
||||
// Track which episodes have shown the nudge this session (not persisted)
|
||||
@@ -20,15 +21,47 @@ export function V4VIndicator() {
|
||||
const v4vStreaming = usePodcastStore((s) => s.v4vStreaming);
|
||||
const v4vTotalStreamed = usePodcastStore((s) => s.v4vTotalStreamed);
|
||||
const playbackState = usePodcastStore((s) => s.playbackState);
|
||||
const { setV4VEnabled, setV4VSatsPerMinute, setV4VStreaming, addStreamedSats } = usePodcastStore.getState();
|
||||
const { setV4VEnabled, setV4VStreaming, addStreamedSats } = usePodcastStore.getState();
|
||||
|
||||
// Show nudge when a V4V episode starts playing and streaming is off
|
||||
const autoEnabled = useV4VStore((s) => s.autoEnabled);
|
||||
const defaultRate = useV4VStore((s) => s.defaultRate);
|
||||
const capReachedReason = useV4VStore((s) => s.capReachedReason);
|
||||
|
||||
const nwcUri = localStorage.getItem(NWC_KEY) ?? "";
|
||||
const hasWallet = !!nwcUri;
|
||||
const hasRecipients = episode?.value && episode.value.length > 0;
|
||||
|
||||
// Auto-start streaming when autoEnabled and V4V episode starts playing
|
||||
useEffect(() => {
|
||||
if (
|
||||
autoEnabled &&
|
||||
playbackState === "playing" &&
|
||||
hasRecipients &&
|
||||
hasWallet &&
|
||||
!v4vStreaming &&
|
||||
episode
|
||||
) {
|
||||
const intervalId = startStreaming(
|
||||
episode,
|
||||
defaultRate,
|
||||
nwcUri,
|
||||
(amount) => addStreamedSats(amount),
|
||||
);
|
||||
if (intervalId >= 0) {
|
||||
setV4VStreaming(true, intervalId);
|
||||
setV4VEnabled(true);
|
||||
}
|
||||
}
|
||||
}, [playbackState, episode?.guid, autoEnabled]);
|
||||
|
||||
// Show nudge when a V4V episode starts playing and streaming is off (manual mode)
|
||||
useEffect(() => {
|
||||
if (
|
||||
playbackState === "playing" &&
|
||||
episode?.value &&
|
||||
episode.value.length > 0 &&
|
||||
!v4vStreaming &&
|
||||
!autoEnabled &&
|
||||
!nudgedGuids.has(episode.guid)
|
||||
) {
|
||||
nudgedGuids.add(episode.guid);
|
||||
@@ -38,11 +71,7 @@ export function V4VIndicator() {
|
||||
return () => {
|
||||
if (nudgeTimer.current) clearTimeout(nudgeTimer.current);
|
||||
};
|
||||
}, [playbackState, episode?.guid, v4vStreaming]);
|
||||
|
||||
const nwcUri = localStorage.getItem(NWC_KEY) ?? "";
|
||||
const hasWallet = !!nwcUri;
|
||||
const hasRecipients = episode?.value && episode.value.length > 0;
|
||||
}, [playbackState, episode?.guid, v4vStreaming, autoEnabled]);
|
||||
|
||||
const toggleStreaming = useCallback(() => {
|
||||
if (!episode || !hasWallet) return;
|
||||
@@ -52,9 +81,10 @@ export function V4VIndicator() {
|
||||
setV4VStreaming(false);
|
||||
setV4VEnabled(false);
|
||||
} else {
|
||||
const rate = autoEnabled ? defaultRate : v4vSatsPerMinute;
|
||||
const intervalId = startStreaming(
|
||||
episode,
|
||||
v4vSatsPerMinute,
|
||||
rate,
|
||||
nwcUri,
|
||||
(amount) => addStreamedSats(amount),
|
||||
);
|
||||
@@ -63,7 +93,7 @@ export function V4VIndicator() {
|
||||
setV4VEnabled(true);
|
||||
}
|
||||
}
|
||||
}, [episode, v4vStreaming, v4vSatsPerMinute, nwcUri, hasWallet]);
|
||||
}, [episode, v4vStreaming, v4vSatsPerMinute, defaultRate, autoEnabled, nwcUri, hasWallet]);
|
||||
|
||||
const handleBoost = useCallback(async () => {
|
||||
if (!episode || !hasWallet || boosting) return;
|
||||
@@ -82,16 +112,26 @@ export function V4VIndicator() {
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => { setOpen(!open); setShowNudge(false); }}
|
||||
className={`text-[11px] px-1.5 py-0.5 rounded-sm transition-colors ${
|
||||
v4vStreaming
|
||||
? "text-amber-400 bg-amber-500/10 animate-pulse"
|
||||
: hasRecipients && !v4vStreaming
|
||||
? "text-amber-400 bg-amber-500/10 hover:bg-amber-500/20"
|
||||
: "text-text-dim hover:text-text"
|
||||
className={`text-[11px] px-1.5 py-0.5 rounded-sm transition-colors flex items-center gap-1 ${
|
||||
capReachedReason
|
||||
? "text-text-dim bg-border/50"
|
||||
: v4vStreaming
|
||||
? "text-amber-400 bg-amber-500/10 animate-pulse"
|
||||
: hasRecipients
|
||||
? "text-amber-400 bg-amber-500/10 hover:bg-amber-500/20"
|
||||
: "text-text-dim hover:text-text"
|
||||
}`}
|
||||
title="Value 4 Value"
|
||||
>
|
||||
{v4vStreaming ? `⚡ ${v4vTotalStreamed} sats` : hasRecipients ? "⚡ V4V" : "V4V"}
|
||||
{v4vStreaming && autoEnabled && (
|
||||
<span className="text-[8px] text-accent bg-accent/15 px-1 rounded-sm font-medium">AUTO</span>
|
||||
)}
|
||||
{capReachedReason
|
||||
? `⚡ ${v4vTotalStreamed} sats (capped)`
|
||||
: v4vStreaming
|
||||
? `⚡ ${v4vTotalStreamed} sats`
|
||||
: hasRecipients ? "⚡ V4V" : "V4V"
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* Brief nudge when V4V episode starts — once per episode per session */}
|
||||
@@ -107,7 +147,15 @@ export function V4VIndicator() {
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-full right-0 mb-2 w-56 bg-bg border border-border rounded-sm shadow-lg p-3 z-50">
|
||||
<div className="text-[11px] text-text font-medium mb-2">Value 4 Value</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[11px] text-text font-medium">Value 4 Value</div>
|
||||
<button
|
||||
onClick={() => { setOpen(false); useUIStore.getState().setView("v4v"); }}
|
||||
className="text-[9px] text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
open v4v ↗
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hasWallet && (
|
||||
<div className="text-[10px] text-text-dim mb-2">
|
||||
@@ -140,25 +188,6 @@ export function V4VIndicator() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rate picker */}
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<span className="text-[10px] text-text-dim shrink-0">Rate:</span>
|
||||
{RATE_OPTIONS.map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
onClick={() => setV4VSatsPerMinute(rate)}
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-sm transition-colors ${
|
||||
v4vSatsPerMinute === rate
|
||||
? "bg-accent/20 text-accent"
|
||||
: "text-text-dim hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{rate}
|
||||
</button>
|
||||
))}
|
||||
<span className="text-[9px] text-text-dim">/min</span>
|
||||
</div>
|
||||
|
||||
{/* Recipients */}
|
||||
{episode.value && episode.value.length > 0 && (
|
||||
<div className="mb-2 border-t border-border pt-2">
|
||||
|
||||
@@ -18,6 +18,7 @@ const NAV_ITEMS = [
|
||||
{ id: "notifications" as const, label: "notifications", icon: "🔔" },
|
||||
{ id: "follows" as const, label: "follows", icon: "♺" },
|
||||
{ id: "zaps" as const, label: "zaps", icon: "⚡" },
|
||||
{ id: "v4v" as const, label: "v4v", icon: "⚡" },
|
||||
{ id: "relays" as const, label: "relays", icon: "⟐" },
|
||||
{ id: "settings" as const, label: "settings", icon: "⚙" },
|
||||
{ id: "about" as const, label: "support", icon: "♥" },
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { usePodcastStore } from "../../stores/podcast";
|
||||
import { useV4VStore } from "../../stores/v4v";
|
||||
|
||||
function BudgetBar({ label, spent, total }: { label: string; spent: number; total: number }) {
|
||||
if (total <= 0) return null;
|
||||
const pct = Math.min(100, Math.round((spent / total) * 100));
|
||||
const isNear = pct >= 80;
|
||||
const isOver = pct >= 100;
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between text-[11px] mb-1">
|
||||
<span className="text-text-muted">{label}</span>
|
||||
<span className={`font-medium ${isOver ? "text-danger" : isNear ? "text-warning" : "text-text"}`}>
|
||||
{spent} / {total} sats
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
isOver ? "bg-danger" : isNear ? "bg-warning" : "bg-accent"
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[9px] text-text-dim mt-0.5 text-right">
|
||||
{total - spent > 0 ? `${total - spent} sats remaining` : "Budget reached"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function V4VDashboard() {
|
||||
const episode = usePodcastStore((s) => s.currentEpisode);
|
||||
const v4vStreaming = usePodcastStore((s) => s.v4vStreaming);
|
||||
const v4vTotalStreamed = usePodcastStore((s) => s.v4vTotalStreamed);
|
||||
|
||||
const autoEnabled = useV4VStore((s) => s.autoEnabled);
|
||||
const perEpisodeCap = useV4VStore((s) => s.perEpisodeCap);
|
||||
const weeklyBudget = useV4VStore((s) => s.weeklyBudget);
|
||||
const defaultRate = useV4VStore((s) => s.defaultRate);
|
||||
const currentEpisodeSats = useV4VStore((s) => s.currentEpisodeSats);
|
||||
const capReachedReason = useV4VStore((s) => s.capReachedReason);
|
||||
const history = useV4VStore((s) => s.history);
|
||||
const weeklySpent = useV4VStore((s) => s.weeklySpent)();
|
||||
|
||||
const hasRecipients = episode?.value && episode.value.length > 0;
|
||||
|
||||
// All-time stats
|
||||
const allTimeSats = history.reduce((sum, e) => sum + e.satsStreamed + e.satsBoosted, 0) + v4vTotalStreamed;
|
||||
const episodesSupported = new Set(history.map((e) => e.episodeGuid)).size;
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto px-6 py-6">
|
||||
{/* Current streaming status */}
|
||||
<section className="mb-6">
|
||||
<h2 className="text-text-dim text-[10px] uppercase tracking-widest mb-3">Now</h2>
|
||||
|
||||
{!episode ? (
|
||||
<div className="text-[12px] text-text-dim">No episode playing.</div>
|
||||
) : !hasRecipients ? (
|
||||
<div className="text-[12px] text-text-dim">
|
||||
Playing <span className="text-text">{episode.title}</span> — no V4V recipients.
|
||||
</div>
|
||||
) : v4vStreaming ? (
|
||||
<div className="bg-amber-500/5 border border-amber-500/20 rounded-sm p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-amber-400 animate-pulse text-[12px]">⚡</span>
|
||||
<span className="text-[12px] text-text font-medium">Streaming</span>
|
||||
{autoEnabled && (
|
||||
<span className="text-[9px] text-accent bg-accent/10 px-1.5 py-0.5 rounded-sm">AUTO</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted truncate">{episode.title}</div>
|
||||
<div className="text-[10px] text-text-dim mt-1">
|
||||
{v4vTotalStreamed} sats this session · {episode.value?.length} recipients
|
||||
</div>
|
||||
</div>
|
||||
) : capReachedReason ? (
|
||||
<div className="bg-danger/5 border border-danger/20 rounded-sm p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-danger text-[12px]">⚡</span>
|
||||
<span className="text-[12px] text-text font-medium">{capReachedReason}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted truncate">{episode.title}</div>
|
||||
<div className="text-[10px] text-text-dim mt-1">
|
||||
{currentEpisodeSats} sats streamed to {episode.value?.length} recipients
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[12px] text-text-dim">
|
||||
Playing <span className="text-text">{episode.title}</span> — V4V available but not streaming.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Budget bars */}
|
||||
{(perEpisodeCap > 0 || weeklyBudget > 0) && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-text-dim text-[10px] uppercase tracking-widest mb-3">Budget</h2>
|
||||
<BudgetBar label="This episode" spent={currentEpisodeSats} total={perEpisodeCap} />
|
||||
<BudgetBar label="This week" spent={weeklySpent} total={weeklyBudget} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Quick stats */}
|
||||
<section>
|
||||
<h2 className="text-text-dim text-[10px] uppercase tracking-widest mb-3">Stats</h2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-bg-raised border border-border rounded-sm p-3 text-center">
|
||||
<div className="text-[16px] text-text font-medium">{allTimeSats}</div>
|
||||
<div className="text-[9px] text-text-dim mt-0.5">total sats</div>
|
||||
</div>
|
||||
<div className="bg-bg-raised border border-border rounded-sm p-3 text-center">
|
||||
<div className="text-[16px] text-text font-medium">{episodesSupported}</div>
|
||||
<div className="text-[9px] text-text-dim mt-0.5">episodes</div>
|
||||
</div>
|
||||
<div className="bg-bg-raised border border-border rounded-sm p-3 text-center">
|
||||
<div className="text-[16px] text-text font-medium">
|
||||
{autoEnabled ? `${defaultRate}/m` : "off"}
|
||||
</div>
|
||||
<div className="text-[9px] text-text-dim mt-0.5">auto-stream</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { useV4VStore, type V4VHistoryEntry } from "../../stores/v4v";
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function HistoryRow({ entry }: { entry: V4VHistoryEntry }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const totalSats = entry.satsStreamed + entry.satsBoosted;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full text-left px-4 py-3 hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px] text-text truncate">{entry.episodeTitle}</div>
|
||||
<div className="text-[10px] text-text-dim truncate">{entry.showTitle}</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right ml-3">
|
||||
<div className="text-[12px] text-amber-400 font-medium">{totalSats} sats</div>
|
||||
<div className="text-[9px] text-text-dim">{formatDate(entry.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 pb-3 space-y-1">
|
||||
{entry.satsStreamed > 0 && (
|
||||
<div className="text-[10px] text-text-dim">Streamed: {entry.satsStreamed} sats</div>
|
||||
)}
|
||||
{entry.satsBoosted > 0 && (
|
||||
<div className="text-[10px] text-text-dim">Boosted: {entry.satsBoosted} sats</div>
|
||||
)}
|
||||
{entry.recipients.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<div className="text-[9px] text-text-dim mb-1">Recipients:</div>
|
||||
{entry.recipients.map((r, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-text-muted truncate">{r.name || r.address.slice(0, 16) + "..."}</span>
|
||||
<span className="text-text-dim shrink-0 ml-2">{r.sats} sats</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function V4VHistory() {
|
||||
const history = useV4VStore((s) => s.history);
|
||||
|
||||
if (history.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="text-center">
|
||||
<div className="text-[13px] text-text-dim">No V4V history yet</div>
|
||||
<div className="text-[11px] text-text-dim/60 mt-1">
|
||||
Start streaming to see your contributions here.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
{history.map((entry, i) => (
|
||||
<HistoryRow key={`${entry.episodeGuid}-${entry.timestamp}-${i}`} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useV4VStore } from "../../stores/v4v";
|
||||
|
||||
const RATE_OPTIONS = [5, 10, 21, 50, 100];
|
||||
|
||||
function InfoIcon({ title }: { title: string }) {
|
||||
return (
|
||||
<span className="text-text-dim cursor-help text-[10px] ml-1" title={title}>
|
||||
ⓘ
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function V4VSettings() {
|
||||
const autoEnabled = useV4VStore((s) => s.autoEnabled);
|
||||
const perEpisodeCap = useV4VStore((s) => s.perEpisodeCap);
|
||||
const weeklyBudget = useV4VStore((s) => s.weeklyBudget);
|
||||
const defaultRate = useV4VStore((s) => s.defaultRate);
|
||||
const {
|
||||
setAutoEnabled, setPerEpisodeCap, setWeeklyBudget, setDefaultRate,
|
||||
} = useV4VStore.getState();
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto px-6 py-6 space-y-6">
|
||||
{/* Auto-enable */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="text-[12px] text-text font-medium">Auto-stream</span>
|
||||
<InfoIcon title="When enabled, V4V streaming starts automatically for every episode that supports it. Requires per-episode cap and weekly budget to be set." />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoEnabled(!autoEnabled)}
|
||||
className={`w-9 h-5 rounded-full transition-colors relative ${
|
||||
autoEnabled ? "bg-accent" : "bg-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
autoEnabled ? "left-4.5" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-text-dim mt-1">
|
||||
Automatically stream sats to creators when playing V4V-enabled episodes.
|
||||
</p>
|
||||
{autoEnabled && (
|
||||
<div className="mt-2 text-[10px] text-success flex items-center gap-1">
|
||||
<span>●</span> Auto-streaming is active
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Per-episode cap */}
|
||||
<section>
|
||||
<div className="flex items-center">
|
||||
<label className="text-[12px] text-text font-medium">Per-episode cap</label>
|
||||
<InfoIcon title="Maximum sats to stream for a single episode. Streaming stops automatically when this limit is reached." />
|
||||
</div>
|
||||
<p className="text-[10px] text-text-dim mt-1 mb-2">
|
||||
Stop streaming after this many sats per episode.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={perEpisodeCap || ""}
|
||||
onChange={(e) => setPerEpisodeCap(parseInt(e.target.value) || 0)}
|
||||
placeholder="e.g. 100"
|
||||
className="w-24 bg-bg-raised border border-border rounded-sm px-2 py-1 text-[11px] text-text placeholder:text-text-dim/50"
|
||||
/>
|
||||
<span className="text-[10px] text-text-dim">sats</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Weekly budget */}
|
||||
<section>
|
||||
<div className="flex items-center">
|
||||
<label className="text-[12px] text-text font-medium">Weekly budget</label>
|
||||
<InfoIcon title="Total sats allowed across all V4V streaming in a rolling 7-day window. Auto-streaming pauses when the budget is exhausted." />
|
||||
</div>
|
||||
<p className="text-[10px] text-text-dim mt-1 mb-2">
|
||||
Total sats allowed per week across all episodes.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={weeklyBudget || ""}
|
||||
onChange={(e) => setWeeklyBudget(parseInt(e.target.value) || 0)}
|
||||
placeholder="e.g. 500"
|
||||
className="w-24 bg-bg-raised border border-border rounded-sm px-2 py-1 text-[11px] text-text placeholder:text-text-dim/50"
|
||||
/>
|
||||
<span className="text-[10px] text-text-dim">sats / week</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Default rate */}
|
||||
<section>
|
||||
<div className="flex items-center">
|
||||
<label className="text-[12px] text-text font-medium">Default streaming rate</label>
|
||||
<InfoIcon title="How many sats per minute to stream by default. Can be overridden per session in the player bar." />
|
||||
</div>
|
||||
<p className="text-[10px] text-text-dim mt-1 mb-2">
|
||||
Sats per minute when auto-streaming.
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{RATE_OPTIONS.map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
onClick={() => setDefaultRate(rate)}
|
||||
className={`text-[11px] px-2.5 py-1 rounded-sm transition-colors ${
|
||||
defaultRate === rate
|
||||
? "bg-accent/20 text-accent font-medium"
|
||||
: "text-text-dim hover:text-text bg-bg-raised"
|
||||
}`}
|
||||
>
|
||||
{rate}
|
||||
</button>
|
||||
))}
|
||||
<span className="text-[10px] text-text-dim ml-1">/min</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Requirements notice */}
|
||||
{!autoEnabled && (perEpisodeCap <= 0 || weeklyBudget <= 0) && (
|
||||
<div className="text-[10px] text-text-dim bg-bg-raised border border-border rounded-sm p-3">
|
||||
Set both a per-episode cap and weekly budget to enable auto-streaming.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from "react";
|
||||
import { V4VDashboard } from "./V4VDashboard";
|
||||
import { V4VSettings } from "./V4VSettings";
|
||||
import { V4VHistory } from "./V4VHistory";
|
||||
|
||||
const TABS = ["dashboard", "settings", "history"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
export function V4VView() {
|
||||
const [tab, setTab] = useState<Tab>("dashboard");
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border shrink-0">
|
||||
<div className="flex items-center px-4 pt-3 pb-0">
|
||||
<h1 className="text-text text-[14px] font-medium mr-6">Value 4 Value</h1>
|
||||
<div className="flex">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-4 py-2 text-[11px] border-b-2 transition-colors ${
|
||||
tab === t
|
||||
? "border-accent text-accent"
|
||||
: "border-transparent text-text-dim hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{tab === "dashboard" && <V4VDashboard />}
|
||||
{tab === "settings" && <V4VSettings />}
|
||||
{tab === "history" && <V4VHistory />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user