Initial commit: self-hosted personal radio station
Flask + React web UI with audio player, podcast queue, feed management, episode browser, music library, schedule viewer, and log tail. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Local Radio</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📻</text></svg>" />
|
||||
</head>
|
||||
<body class="bg-radio-bg text-radio-text">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2754
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "localradio-ui",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: { config: path.join(__dirname, "tailwind.config.js") },
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createHashRouter, RouterProvider } from "react-router-dom";
|
||||
import Layout from "./components/Layout";
|
||||
import Dashboard from "./components/Dashboard";
|
||||
import QueuePanel from "./components/QueuePanel";
|
||||
import EpisodesPanel from "./components/EpisodesPanel";
|
||||
import FeedsPanel from "./components/FeedsPanel";
|
||||
import SchedulePanel from "./components/SchedulePanel";
|
||||
import MusicPanel from "./components/MusicPanel";
|
||||
import LogsPanel from "./components/LogsPanel";
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ path: "queue", element: <QueuePanel /> },
|
||||
{ path: "episodes", element: <EpisodesPanel /> },
|
||||
{ path: "feeds", element: <FeedsPanel /> },
|
||||
{ path: "schedule", element: <SchedulePanel /> },
|
||||
{ path: "music", element: <MusicPanel /> },
|
||||
{ path: "logs", element: <LogsPanel /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export default function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const BASE = "/api";
|
||||
|
||||
async function fetchJSON(path, options = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => fetchJSON("/status"),
|
||||
getQueue: () => fetchJSON("/queue"),
|
||||
enqueue: (data) =>
|
||||
fetchJSON("/queue", { method: "POST", body: JSON.stringify(data) }),
|
||||
dequeue: (id) => fetchJSON(`/queue/${id}`, { method: "DELETE" }),
|
||||
|
||||
getEpisodes: (params = {}) => {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return fetchJSON(`/episodes?${qs}`);
|
||||
},
|
||||
getEpisodeFeeds: () => fetchJSON("/episodes/feeds"),
|
||||
|
||||
getFeeds: () => fetchJSON("/feeds"),
|
||||
addFeed: (data) =>
|
||||
fetchJSON("/feeds", { method: "POST", body: JSON.stringify(data) }),
|
||||
updateFeed: (name, data) =>
|
||||
fetchJSON(`/feeds/${encodeURIComponent(name)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteFeed: (name) =>
|
||||
fetchJSON(`/feeds/${encodeURIComponent(name)}`, { method: "DELETE" }),
|
||||
|
||||
getSchedule: () => fetchJSON("/schedule"),
|
||||
getMusic: () => fetchJSON("/music"),
|
||||
getMusicFiles: (folder) => fetchJSON(`/music/${encodeURIComponent(folder)}/files`),
|
||||
moveFile: (file, from_, to) =>
|
||||
fetchJSON("/music/move", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ file, from: from_, to }),
|
||||
}),
|
||||
getNextTrack: () => fetchJSON("/stream/next"),
|
||||
markDone: (id) => fetchJSON("/stream/done", { method: "POST", body: JSON.stringify({ id }) }),
|
||||
skip: () => fetchJSON("/stream/skip", { method: "POST" }),
|
||||
getLogs: (params = {}) => {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return fetchJSON(`/logs?${qs}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
function formatTime(s) {
|
||||
if (!s || !isFinite(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function AudioPlayer() {
|
||||
const audioRef = useRef(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(0.8);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [track, setTrack] = useState(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [seeking, setSeeking] = useState(false);
|
||||
const activeRef = useRef(false); // whether the player should be running
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) audioRef.current.volume = volume;
|
||||
}, [volume]);
|
||||
|
||||
// Time update for progress bar
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const onTime = () => { if (!seeking) setProgress(audio.currentTime); };
|
||||
const onDur = () => setDuration(audio.duration);
|
||||
audio.addEventListener("timeupdate", onTime);
|
||||
audio.addEventListener("durationchange", onDur);
|
||||
return () => {
|
||||
audio.removeEventListener("timeupdate", onTime);
|
||||
audio.removeEventListener("durationchange", onDur);
|
||||
};
|
||||
}, [seeking]);
|
||||
|
||||
const loadAndPlay = useCallback(async () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !activeRef.current) return;
|
||||
setError(false);
|
||||
try {
|
||||
const next = await api.getNextTrack();
|
||||
setTrack(next);
|
||||
audio.src = `/api/stream/play/${next.id}`;
|
||||
audio.load();
|
||||
await audio.play();
|
||||
setPlaying(true);
|
||||
} catch {
|
||||
setError(true);
|
||||
setPlaying(false);
|
||||
// Retry after a pause
|
||||
if (activeRef.current) setTimeout(loadAndPlay, 3000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// When track ends, load next
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const onEnded = () => {
|
||||
if (track?.source === "queue" && track?.id) {
|
||||
api.markDone(track.id).catch(() => {});
|
||||
}
|
||||
loadAndPlay();
|
||||
};
|
||||
audio.addEventListener("ended", onEnded);
|
||||
return () => audio.removeEventListener("ended", onEnded);
|
||||
}, [track, loadAndPlay]);
|
||||
|
||||
const toggle = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (playing) {
|
||||
audio.pause();
|
||||
setPlaying(false);
|
||||
activeRef.current = false;
|
||||
} else {
|
||||
activeRef.current = true;
|
||||
if (audio.src && audio.src !== window.location.href) {
|
||||
audio.play().catch(() => {});
|
||||
setPlaying(true);
|
||||
} else {
|
||||
loadAndPlay();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const skip = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
audio.pause();
|
||||
if (track?.source === "queue") {
|
||||
api.skip().catch(() => {});
|
||||
}
|
||||
loadAndPlay();
|
||||
};
|
||||
|
||||
const handleSeek = (e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setProgress(val);
|
||||
if (audioRef.current) audioRef.current.currentTime = val;
|
||||
setSeeking(false);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = !muted;
|
||||
setMuted(!muted);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-radio-card border-t border-radio-border z-50">
|
||||
{/* Progress bar */}
|
||||
{playing && duration > 0 && (
|
||||
<div className="px-4 pt-1 flex items-center gap-2 text-xs text-radio-muted">
|
||||
<span className="w-10 text-right">{formatTime(progress)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
step="0.1"
|
||||
value={progress}
|
||||
onMouseDown={() => setSeeking(true)}
|
||||
onTouchStart={() => setSeeking(true)}
|
||||
onChange={handleSeek}
|
||||
className="flex-1 accent-radio-accent h-1"
|
||||
/>
|
||||
<span className="w-10">{formatTime(duration)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 py-2 flex items-center gap-4">
|
||||
<audio ref={audioRef} preload="auto" />
|
||||
|
||||
{/* Play/Pause */}
|
||||
<button onClick={toggle} className="btn btn-primary w-10 h-10 flex items-center justify-center rounded-full p-0">
|
||||
{playing ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<polygon points="5,3 19,12 5,21" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Skip */}
|
||||
<button onClick={skip} className="btn-ghost p-1 rounded" title="Skip track">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<polygon points="5,4 15,12 5,20" />
|
||||
<rect x="15" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Equalizer / Status */}
|
||||
<div className="flex items-end gap-0.5 h-5">
|
||||
{playing && !error ? (
|
||||
<>
|
||||
<div className="eq-bar" />
|
||||
<div className="eq-bar" />
|
||||
<div className="eq-bar" />
|
||||
<div className="eq-bar" />
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-radio-muted">
|
||||
{error ? "Stream unavailable" : "Stopped"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Now Playing */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{track?.title || "Local Radio"}
|
||||
</div>
|
||||
<div className="text-xs text-radio-muted">
|
||||
{playing
|
||||
? track?.source === "queue" ? "Podcast" : "On Air"
|
||||
: error ? "Could not connect" : "Click play to listen"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<button onClick={toggleMute} className="btn-ghost p-1 rounded">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
{muted ? (
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5zM23 9l-6 6M17 9l6 6" />
|
||||
) : (
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 010 7.07M19.07 4.93a10 10 0 010 14.14" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => {
|
||||
setVolume(parseFloat(e.target.value));
|
||||
if (muted) setMuted(false);
|
||||
}}
|
||||
className="w-24 accent-radio-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useOutletContext, Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
|
||||
function StatCard({ label, value, sub, color = "text-radio-text" }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="text-xs text-radio-muted uppercase tracking-wide">{label}</div>
|
||||
<div className={`text-2xl font-bold mt-1 ${color}`}>{value}</div>
|
||||
{sub && <div className="text-xs text-radio-muted mt-1">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { status } = useOutletContext();
|
||||
const [queue, setQueue] = useState([]);
|
||||
const [recentEpisodes, setRecentEpisodes] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.getQueue().then(setQueue).catch(() => {});
|
||||
api.getEpisodes({ per_page: 5 }).then((d) => setRecentEpisodes(d.episodes)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const stream = status?.stream || {};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||
|
||||
{/* Now Playing */}
|
||||
<div className="card mb-6 border-radio-accent/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full flex-shrink-0 ${stream.online ? "bg-radio-success animate-pulse" : "bg-radio-danger"}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-radio-muted uppercase tracking-wide">Now Playing</div>
|
||||
<div className="text-lg font-semibold truncate mt-0.5">
|
||||
{stream.now_playing || "Nothing playing"}
|
||||
</div>
|
||||
</div>
|
||||
{status?.current_block && (
|
||||
<div className="badge badge-yellow">
|
||||
{status.current_block.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard
|
||||
label="Stream"
|
||||
value={stream.online ? "Online" : "Offline"}
|
||||
color={stream.online ? "text-radio-success" : "text-radio-danger"}
|
||||
/>
|
||||
<StatCard label="Tracks" value={stream.tracks ?? 0} />
|
||||
<StatCard label="Queue Depth" value={status?.queue_depth ?? 0} />
|
||||
<StatCard
|
||||
label="Episodes"
|
||||
value={status?.total_episodes ?? 0}
|
||||
sub={`${status?.active_feeds ?? 0} feeds`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Queue Preview */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-semibold">Podcast Queue</h2>
|
||||
<Link to="/queue" className="text-xs text-radio-accent hover:text-yellow-400">
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
{queue.length === 0 ? (
|
||||
<p className="text-sm text-radio-muted">Queue empty — music is playing</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{queue.slice(0, 5).map((item) => (
|
||||
<li key={item.id} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-radio-accent font-mono text-xs w-6">#{item.position}</span>
|
||||
<span className="truncate">{item.title}</span>
|
||||
<span className="text-radio-muted text-xs ml-auto flex-shrink-0">{item.feed_name}</span>
|
||||
</li>
|
||||
))}
|
||||
{queue.length > 5 && (
|
||||
<li className="text-xs text-radio-muted">+{queue.length - 5} more</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Episodes */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-semibold">Recent Episodes</h2>
|
||||
<Link to="/episodes" className="text-xs text-radio-accent hover:text-yellow-400">
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
{recentEpisodes.length === 0 ? (
|
||||
<p className="text-sm text-radio-muted">No episodes yet</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{recentEpisodes.map((ep) => (
|
||||
<li key={ep.id} className="text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">{ep.title}</span>
|
||||
<div className="flex gap-1 ml-auto flex-shrink-0">
|
||||
{ep.downloaded ? <span className="badge badge-green">DL</span> : null}
|
||||
{ep.queued ? <span className="badge badge-yellow">Q</span> : null}
|
||||
{ep.played ? <span className="badge badge-blue">P</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-radio-muted">{ep.feed_name}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
export default function EpisodesPanel() {
|
||||
const [data, setData] = useState({ episodes: [], total: 0, page: 1, pages: 1 });
|
||||
const [feedFilter, setFeedFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [feedNames, setFeedNames] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [enqueuing, setEnqueuing] = useState(null);
|
||||
|
||||
const handleEnqueue = async (ep) => {
|
||||
if (!ep.downloaded) return;
|
||||
setEnqueuing(ep.id);
|
||||
try {
|
||||
await api.enqueue({ episode_id: ep.id });
|
||||
fetchEpisodes();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
setEnqueuing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchEpisodes = () => {
|
||||
const params = { page, per_page: 20 };
|
||||
if (feedFilter) params.feed = feedFilter;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
api.getEpisodes(params).then(setData).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api.getEpisodeFeeds().then(setFeedNames).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(fetchEpisodes, [page, feedFilter, statusFilter]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Episodes</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 mb-4 flex-wrap">
|
||||
<select
|
||||
value={feedFilter}
|
||||
onChange={(e) => { setFeedFilter(e.target.value); setPage(1); }}
|
||||
className="input text-sm"
|
||||
>
|
||||
<option value="">All feeds</option>
|
||||
{feedNames.map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
className="input text-sm"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="downloaded">Downloaded</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="played">Played</option>
|
||||
</select>
|
||||
|
||||
<span className="text-sm text-radio-muted self-center ml-auto">
|
||||
{data.total} episode{data.total !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-radio-muted text-xs uppercase border-b border-radio-border">
|
||||
<th className="text-left py-2 px-2">Title</th>
|
||||
<th className="text-left py-2 px-2">Feed</th>
|
||||
<th className="text-left py-2 px-2">Published</th>
|
||||
<th className="text-left py-2 px-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.episodes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="4" className="text-center py-8 text-radio-muted">
|
||||
No episodes found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.episodes.map((ep) => (
|
||||
<tr
|
||||
key={ep.id}
|
||||
className={`border-b border-radio-border/50 hover:bg-radio-border/10 ${ep.downloaded ? "cursor-pointer" : ""}`}
|
||||
onClick={() => handleEnqueue(ep)}
|
||||
title={ep.downloaded ? (ep.queued ? "Already in queue" : "Click to add to queue") : ""}
|
||||
>
|
||||
<td className="py-2 px-2 max-w-xs truncate">{ep.title}</td>
|
||||
<td className="py-2 px-2 text-radio-muted">{ep.feed_name}</td>
|
||||
<td className="py-2 px-2 text-radio-muted whitespace-nowrap">
|
||||
{ep.pub_date ? new Date(ep.pub_date).toLocaleDateString() : "-"}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{ep.downloaded ? <span className="badge badge-green">DL</span> : null}
|
||||
{ep.queued ? <span className="badge badge-yellow">Q</span> : null}
|
||||
{ep.played ? <span className="badge badge-blue">P</span> : null}
|
||||
{ep.downloaded && !ep.queued && (
|
||||
<button
|
||||
className="ml-1 text-radio-accent hover:text-radio-accent/80 disabled:opacity-50"
|
||||
disabled={enqueuing === ep.id}
|
||||
onClick={(e) => { e.stopPropagation(); handleEnqueue(ep); }}
|
||||
title="Add to queue"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{enqueuing === ep.id && (
|
||||
<span className="text-xs text-radio-accent ml-1">Queuing...</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data.pages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="btn btn-ghost text-sm disabled:opacity-30"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-radio-muted">
|
||||
Page {data.page} of {data.pages}
|
||||
</span>
|
||||
<button
|
||||
disabled={page >= data.pages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="btn btn-ghost text-sm disabled:opacity-30"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
function AddFeedModal({ onClose, onAdded }) {
|
||||
const [form, setForm] = useState({ name: "", url: "", priority: 10, max_episodes: 20 });
|
||||
const [error, setError] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.addFeed(form);
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="card w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="font-semibold text-lg mb-4">Add Feed</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-radio-muted block mb-1">Name</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="My Podcast"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-radio-muted block mb-1">RSS URL</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
type="url"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
placeholder="https://feeds.example.com/podcast.xml"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-radio-muted block mb-1">Priority</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
type="number"
|
||||
value={form.priority}
|
||||
onChange={(e) => setForm({ ...form, priority: parseInt(e.target.value) || 10 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-radio-muted block mb-1">Max Episodes</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
type="number"
|
||||
value={form.max_episodes}
|
||||
onChange={(e) => setForm({ ...form, max_episodes: parseInt(e.target.value) || 20 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="text-radio-danger text-sm">{error}</div>}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button type="submit" disabled={saving} className="btn btn-primary flex-1">
|
||||
{saving ? "Adding..." : "Add Feed"}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="btn btn-ghost flex-1">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FeedsPanel() {
|
||||
const [feeds, setFeeds] = useState([]);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const refresh = () => api.getFeeds().then(setFeeds).catch((e) => setError(e.message));
|
||||
|
||||
useEffect(() => { refresh(); }, []);
|
||||
|
||||
const handleToggle = async (name, enabled) => {
|
||||
try {
|
||||
await api.updateFeed(name, { enabled: !enabled });
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (name) => {
|
||||
if (!confirm(`Remove feed "${name}"? This won't delete downloaded episodes.`)) return;
|
||||
try {
|
||||
await api.deleteFeed(name);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Feeds</h1>
|
||||
<button onClick={() => setShowAdd(true)} className="btn btn-primary text-sm">
|
||||
Add Feed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="card border-radio-danger/30 text-radio-danger mb-4 text-sm">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 underline">dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feeds.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<div className="text-radio-muted text-lg mb-2">No feeds configured</div>
|
||||
<div className="text-sm text-radio-muted">Add an RSS feed to start receiving podcast episodes.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{feeds.map((feed) => (
|
||||
<div key={feed.name} className="card flex items-center gap-4">
|
||||
{/* Toggle */}
|
||||
<button
|
||||
onClick={() => handleToggle(feed.name, feed.enabled)}
|
||||
className={`w-12 h-6 rounded-full flex items-center transition-colors flex-shrink-0 ${
|
||||
feed.enabled ? "bg-radio-success" : "bg-radio-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-5 h-5 rounded-full bg-white transition-transform shadow ${
|
||||
feed.enabled ? "translate-x-6" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{feed.name}</div>
|
||||
<div className="text-xs text-radio-muted truncate">{feed.url}</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-radio-muted">
|
||||
<span>{feed.episode_count} episodes</span>
|
||||
<span>Priority: {feed.priority}</span>
|
||||
{feed.last_poll && (
|
||||
<span>Last poll: {new Date(feed.last_poll + "Z").toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={() => handleDelete(feed.name)}
|
||||
className="btn btn-danger text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && <AddFeedModal onClose={() => setShowAdd(false)} onAdded={refresh} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import AudioPlayer from "./AudioPlayer";
|
||||
import { api } from "../api";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: "/", label: "Dashboard", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" },
|
||||
{ to: "/queue", label: "Queue", icon: "M4 6h16M4 10h16M4 14h16M4 18h16" },
|
||||
{ to: "/episodes", label: "Episodes", icon: "M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" },
|
||||
{ to: "/feeds", label: "Feeds", icon: "M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-6 0a1 1 0 11-2 0 1 1 0 012 0z" },
|
||||
{ to: "/schedule", label: "Schedule", icon: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" },
|
||||
{ to: "/music", label: "Music", icon: "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" },
|
||||
{ to: "/logs", label: "Logs", icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const poll = () => api.getStatus().then(setStatus).catch(() => {});
|
||||
poll();
|
||||
const id = setInterval(poll, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const streamOnline = status?.stream?.online;
|
||||
const nowPlaying = status?.stream?.now_playing;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen pb-16">
|
||||
{/* Sidebar */}
|
||||
<aside className={`${sidebarOpen ? "w-56" : "w-16"} flex-shrink-0 bg-radio-card border-r border-radio-border flex flex-col transition-all duration-200`}>
|
||||
{/* Logo / Header */}
|
||||
<div className="p-4 border-b border-radio-border flex items-center gap-3">
|
||||
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="text-radio-accent hover:text-yellow-400 transition-colors">
|
||||
<svg className="w-7 h-7" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728M12 12h.01M8.464 15.536a5 5 0 010-7.072m7.072 0a5 5 0 010 7.072" />
|
||||
</svg>
|
||||
</button>
|
||||
{sidebarOpen && (
|
||||
<div>
|
||||
<div className="font-bold text-radio-accent text-sm">LOCAL RADIO</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={`w-2 h-2 rounded-full ${streamOnline ? "bg-radio-success animate-pulse" : "bg-radio-danger"}`} />
|
||||
<span className="text-xs text-radio-muted">{streamOnline ? "On Air" : "Offline"}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-2">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? "text-radio-accent bg-radio-accent/10 border-r-2 border-radio-accent"
|
||||
: "text-radio-muted hover:text-radio-text hover:bg-radio-border/20"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
|
||||
</svg>
|
||||
{sidebarOpen && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet context={{ status }} />
|
||||
</main>
|
||||
|
||||
{/* Persistent audio player */}
|
||||
<AudioPlayer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
const LOG_FILES = ["poller", "liquidsoap", "icecast_access", "icecast_error"];
|
||||
|
||||
export default function LogsPanel() {
|
||||
const [logFile, setLogFile] = useState("poller");
|
||||
const [lines, setLines] = useState([]);
|
||||
const [lineCount, setLineCount] = useState(100);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [exists, setExists] = useState(true);
|
||||
const bottomRef = useRef(null);
|
||||
|
||||
const fetchLogs = () => {
|
||||
api.getLogs({ file: logFile, lines: lineCount })
|
||||
.then((data) => {
|
||||
setLines(data.lines);
|
||||
setExists(data.exists);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(fetchLogs, [logFile, lineCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const id = setInterval(fetchLogs, 5000);
|
||||
return () => clearInterval(id);
|
||||
}, [autoRefresh, logFile, lineCount]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [lines]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
|
||||
<h1 className="text-2xl font-bold">Logs</h1>
|
||||
<div className="flex gap-3 items-center">
|
||||
<select
|
||||
value={logFile}
|
||||
onChange={(e) => setLogFile(e.target.value)}
|
||||
className="input text-sm"
|
||||
>
|
||||
{LOG_FILES.map((f) => (
|
||||
<option key={f} value={f}>{f}.log</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={lineCount}
|
||||
onChange={(e) => setLineCount(parseInt(e.target.value))}
|
||||
className="input text-sm"
|
||||
>
|
||||
<option value={50}>50 lines</option>
|
||||
<option value={100}>100 lines</option>
|
||||
<option value={200}>200 lines</option>
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
className="accent-radio-accent"
|
||||
/>
|
||||
Auto-refresh
|
||||
</label>
|
||||
|
||||
<button onClick={fetchLogs} className="btn btn-ghost text-sm">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-0">
|
||||
{!exists ? (
|
||||
<div className="p-8 text-center text-radio-muted">
|
||||
Log file <code>{logFile}.log</code> does not exist yet
|
||||
</div>
|
||||
) : lines.length === 0 ? (
|
||||
<div className="p-8 text-center text-radio-muted">
|
||||
Log file is empty
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-xs font-mono overflow-x-auto p-4 max-h-[70vh] overflow-y-auto leading-relaxed">
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`py-0.5 ${
|
||||
line.includes("ERROR") || line.includes("error")
|
||||
? "text-radio-danger"
|
||||
: line.includes("WARNING") || line.includes("warning")
|
||||
? "text-yellow-400"
|
||||
: "text-radio-muted"
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function FolderCard({ folder, name, path, count, isFallback, allFolders, onRefresh }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [moving, setMoving] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadFiles = () => {
|
||||
setLoading(true);
|
||||
api.getMusicFiles(folder).then((d) => {
|
||||
setFiles(d.files);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
if (!expanded) loadFiles();
|
||||
setExpanded(!expanded);
|
||||
};
|
||||
|
||||
const handleMove = async (filename, destFolder) => {
|
||||
setMoving(filename);
|
||||
setError(null);
|
||||
try {
|
||||
await api.moveFile(filename, folder, destFolder);
|
||||
loadFiles();
|
||||
onRefresh();
|
||||
} catch (e) {
|
||||
setError(`${filename}: ${e.message}`);
|
||||
} finally {
|
||||
setMoving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const destinations = allFolders.filter((f) => f !== folder);
|
||||
|
||||
return (
|
||||
<div className={`card ${isFallback ? (count === 0 ? "border-radio-danger/50" : "border-radio-success/30") : ""}`}>
|
||||
<button onClick={toggle} className="w-full flex items-center justify-between text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className={`w-8 h-8 ${isFallback ? "text-radio-muted" : "text-radio-accent"}`} fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
{isFallback ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" />
|
||||
)}
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-medium capitalize">{name}</div>
|
||||
<div className="text-xs text-radio-muted">{isFallback ? "Emergency playlist" : path}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-2xl font-bold ${count > 0 ? (isFallback ? "text-radio-success" : "text-radio-text") : "text-radio-danger"}`}>
|
||||
{count}
|
||||
</span>
|
||||
<svg className={`w-4 h-4 text-radio-muted transition-transform ${expanded ? "rotate-180" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{count === 0 && !expanded && (
|
||||
<div className="text-xs text-radio-danger mt-2">
|
||||
{isFallback
|
||||
? "Add at least one audio file to prevent silence when all other sources fail"
|
||||
: "No audio files — this schedule block will be silent"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 border-t border-radio-border pt-3">
|
||||
{loading ? (
|
||||
<div className="text-sm text-radio-muted">Loading...</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-sm text-radio-muted">No files</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{files.map((file) => (
|
||||
<li key={file.name} className="flex items-center gap-2 text-sm group">
|
||||
<svg className="w-4 h-4 text-radio-muted flex-shrink-0" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H2.25A.75.75 0 011.5 15V9a.75.75 0 01.75-.75h4.5z" />
|
||||
</svg>
|
||||
<span className="truncate flex-1" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs text-radio-muted flex-shrink-0">
|
||||
{formatSize(file.size)}
|
||||
</span>
|
||||
{moving === file.name ? (
|
||||
<span className="text-xs text-radio-accent flex-shrink-0">Moving...</span>
|
||||
) : (
|
||||
<select
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-xs bg-radio-bg border border-radio-border rounded px-1 py-0.5 text-radio-text flex-shrink-0"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value) handleMove(file.name, e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Move to...</option>
|
||||
{destinations.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-xs text-radio-danger mt-2">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MusicPanel() {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
const load = () => api.getMusic().then(setData).catch(() => {});
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
if (!data) return <div className="text-radio-muted">Loading...</div>;
|
||||
|
||||
const allFolders = [
|
||||
...data.directories.map((d) => d.folder),
|
||||
"fallback",
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Music Library</h1>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{data.directories.map((dir) => (
|
||||
<FolderCard
|
||||
key={dir.folder}
|
||||
folder={dir.folder}
|
||||
name={dir.folder}
|
||||
path={dir.path}
|
||||
count={dir.file_count}
|
||||
isFallback={false}
|
||||
allFolders={allFolders}
|
||||
onRefresh={load}
|
||||
/>
|
||||
))}
|
||||
|
||||
<FolderCard
|
||||
folder="fallback"
|
||||
name="Fallback"
|
||||
path={data.fallback.path}
|
||||
count={data.fallback.file_count}
|
||||
isFallback={true}
|
||||
allFolders={allFolders}
|
||||
onRefresh={load}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
export default function QueuePanel() {
|
||||
const [queue, setQueue] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showEnqueue, setShowEnqueue] = useState(false);
|
||||
const [episodes, setEpisodes] = useState([]);
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
api.getQueue()
|
||||
.then(setQueue)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(refresh, []);
|
||||
|
||||
const handleDequeue = async (id) => {
|
||||
try {
|
||||
await api.dequeue(id);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const openEnqueueModal = async () => {
|
||||
try {
|
||||
const data = await api.getEpisodes({ status: "downloaded", per_page: 50 });
|
||||
setEpisodes(data.episodes.filter((ep) => !ep.queued));
|
||||
setShowEnqueue(true);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnqueue = async (episodeId) => {
|
||||
try {
|
||||
await api.enqueue({ episode_id: episodeId });
|
||||
setShowEnqueue(false);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Podcast Queue</h1>
|
||||
<button onClick={openEnqueueModal} className="btn btn-primary text-sm">
|
||||
Enqueue Episode
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="card border-radio-danger/30 text-radio-danger mb-4 text-sm">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 underline">dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-radio-muted">Loading...</div>
|
||||
) : queue.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<div className="text-radio-muted text-lg mb-2">Queue is empty</div>
|
||||
<div className="text-sm text-radio-muted">Music is playing. Podcasts will appear here when new episodes are detected.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{queue.map((item) => (
|
||||
<div key={item.id} className="card flex items-center gap-4">
|
||||
<span className="text-radio-accent font-mono text-lg w-8 text-center">
|
||||
{item.position}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{item.title}</div>
|
||||
<div className="text-sm text-radio-muted">{item.feed_name}</div>
|
||||
<div className="text-xs text-radio-muted">
|
||||
Enqueued: {new Date(item.enqueued + "Z").toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDequeue(item.id)}
|
||||
className="btn btn-danger text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enqueue Modal */}
|
||||
{showEnqueue && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowEnqueue(false)}>
|
||||
<div className="card w-full max-w-lg max-h-[80vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="font-semibold text-lg mb-4">Enqueue Episode</h2>
|
||||
{episodes.length === 0 ? (
|
||||
<p className="text-radio-muted text-sm">No downloaded episodes available to enqueue.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{episodes.map((ep) => (
|
||||
<li key={ep.id} className="flex items-center gap-3 p-2 rounded hover:bg-radio-border/20">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">{ep.title}</div>
|
||||
<div className="text-xs text-radio-muted">{ep.feed_name}</div>
|
||||
</div>
|
||||
<button onClick={() => handleEnqueue(ep.id)} className="btn btn-primary text-xs py-1 px-3">
|
||||
Add
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<button onClick={() => setShowEnqueue(false)} className="btn btn-ghost mt-4 w-full text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "../api";
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
const DAYS = [
|
||||
{ num: 1, name: "Mon" },
|
||||
{ num: 2, name: "Tue" },
|
||||
{ num: 3, name: "Wed" },
|
||||
{ num: 4, name: "Thu" },
|
||||
{ num: 5, name: "Fri" },
|
||||
{ num: 6, name: "Sat" },
|
||||
{ num: 7, name: "Sun" },
|
||||
];
|
||||
|
||||
const FOLDER_COLORS = {
|
||||
morning: "bg-yellow-500/30 border-yellow-500/50",
|
||||
day: "bg-orange-500/30 border-orange-500/50",
|
||||
night: "bg-indigo-500/30 border-indigo-500/50",
|
||||
weekend: "bg-emerald-500/30 border-emerald-500/50",
|
||||
};
|
||||
|
||||
function getBlockForHour(blocks, dayNum, hour) {
|
||||
const timeStr = `${String(hour).padStart(2, "0")}:00`;
|
||||
for (const block of blocks) {
|
||||
if (!block.days.includes(dayNum)) continue;
|
||||
const start = block.start;
|
||||
const end = block.end;
|
||||
if (start <= end) {
|
||||
if (timeStr >= start && timeStr < end) return block;
|
||||
} else {
|
||||
if (timeStr >= start || timeStr < end) return block;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function SchedulePanel() {
|
||||
const [blocks, setBlocks] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSchedule().then(setBlocks).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Schedule</h1>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-4 mb-4 flex-wrap">
|
||||
{blocks.map((b) => (
|
||||
<div key={b.name} className="flex items-center gap-2">
|
||||
<div className={`w-4 h-4 rounded border ${FOLDER_COLORS[b.folder] || "bg-gray-500/30 border-gray-500/50"}`} />
|
||||
<span className="text-sm">
|
||||
{b.name}
|
||||
{b.active && <span className="badge badge-green ml-2">Active</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="card overflow-x-auto">
|
||||
<div className="min-w-[700px]">
|
||||
{/* Hour headers */}
|
||||
<div className="flex">
|
||||
<div className="w-12 flex-shrink-0" />
|
||||
{HOURS.map((h) => (
|
||||
<div key={h} className="flex-1 text-center text-xs text-radio-muted py-1">
|
||||
{h}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day rows */}
|
||||
{DAYS.map((day) => (
|
||||
<div key={day.num} className="flex items-center">
|
||||
<div className="w-12 flex-shrink-0 text-xs text-radio-muted font-medium text-right pr-2 py-1">
|
||||
{day.name}
|
||||
</div>
|
||||
{HOURS.map((h) => {
|
||||
const block = getBlockForHour(blocks, day.num, h);
|
||||
const colorClass = block
|
||||
? FOLDER_COLORS[block.folder] || "bg-gray-500/30 border-gray-500/50"
|
||||
: "bg-radio-border/20 border-transparent";
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
className={`flex-1 h-8 border ${colorClass} ${block?.active ? "ring-1 ring-radio-accent" : ""}`}
|
||||
title={block ? `${block.name} (${block.folder})` : "No schedule"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block details */}
|
||||
<div className="grid md:grid-cols-2 gap-3 mt-6">
|
||||
{blocks.map((b) => (
|
||||
<div key={b.name} className={`card ${b.active ? "border-radio-accent/50" : ""}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{b.name}</span>
|
||||
{b.active && <span className="badge badge-green">Active</span>}
|
||||
</div>
|
||||
<div className="text-sm text-radio-muted mt-1">
|
||||
{b.day_names.join(", ")} · {b.start} - {b.end}
|
||||
</div>
|
||||
<div className="text-xs text-radio-muted mt-1">
|
||||
Folder: <code className="text-radio-accent">{b.folder}/</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
background-color: #0f1419;
|
||||
color: #e2e8f0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0f1419;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2a3040;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-radio-card border border-radio-border rounded-lg p-4;
|
||||
}
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-150;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-radio-accent text-black hover:bg-yellow-400;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply text-radio-muted hover:text-radio-text;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background-color: rgba(42, 48, 64, 0.3);
|
||||
}
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2 rounded text-xs font-medium;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.badge-green {
|
||||
background-color: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
.badge-red {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
.badge-yellow {
|
||||
background-color: rgba(234, 179, 8, 0.2);
|
||||
color: #facc15;
|
||||
}
|
||||
.badge-blue {
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
.input {
|
||||
@apply bg-radio-bg border border-radio-border rounded-lg px-3 py-2 text-radio-text;
|
||||
}
|
||||
.input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 1px #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
.eq-bar {
|
||||
width: 4px;
|
||||
background-color: #f59e0b;
|
||||
border-radius: 9999px;
|
||||
animation: eq-bounce 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
.eq-bar:nth-child(2) { animation-delay: 0.2s; }
|
||||
.eq-bar:nth-child(3) { animation-delay: 0.4s; }
|
||||
.eq-bar:nth-child(4) { animation-delay: 0.1s; }
|
||||
|
||||
@keyframes eq-bounce {
|
||||
0% { height: 4px; }
|
||||
100% { height: 16px; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
path.join(__dirname, "index.html"),
|
||||
path.join(__dirname, "src/**/*.{js,jsx}"),
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
radio: {
|
||||
bg: "#0f1419",
|
||||
card: "#1a1f2e",
|
||||
border: "#2a3040",
|
||||
accent: "#f59e0b",
|
||||
"accent-dim": "#b45309",
|
||||
text: "#e2e8f0",
|
||||
muted: "#64748b",
|
||||
success: "#22c55e",
|
||||
danger: "#ef4444",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:5000",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user