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 @@
|
||||
from .status import bp as status_bp
|
||||
from .queue import bp as queue_bp
|
||||
from .episodes import bp as episodes_bp
|
||||
from .feeds import bp as feeds_bp
|
||||
from .schedule import bp as schedule_bp
|
||||
from .music import bp as music_bp
|
||||
from .logs import bp as logs_bp
|
||||
from .stream import bp as stream_bp
|
||||
|
||||
__all__ = [
|
||||
"status_bp", "queue_bp", "episodes_bp", "feeds_bp",
|
||||
"schedule_bp", "music_bp", "logs_bp", "stream_bp",
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""GET /api/episodes — paginated episode browser."""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..db import get_db
|
||||
|
||||
bp = Blueprint("episodes", __name__)
|
||||
|
||||
|
||||
@bp.route("/episodes")
|
||||
def list_episodes():
|
||||
db = get_db()
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = request.args.get("per_page", 20, type=int)
|
||||
per_page = min(per_page, 100)
|
||||
feed = request.args.get("feed")
|
||||
status = request.args.get("status")
|
||||
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if feed:
|
||||
conditions.append("feed_name = ?")
|
||||
params.append(feed)
|
||||
|
||||
if status == "downloaded":
|
||||
conditions.append("downloaded = 1")
|
||||
elif status == "queued":
|
||||
conditions.append("queued = 1")
|
||||
elif status == "played":
|
||||
conditions.append("played = 1")
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
|
||||
total = db.execute(
|
||||
f"SELECT COUNT(*) as c FROM episodes {where}", params
|
||||
).fetchone()["c"]
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
rows = db.execute(
|
||||
f"""SELECT * FROM episodes {where}
|
||||
ORDER BY discovered DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
params + [per_page, offset],
|
||||
).fetchall()
|
||||
|
||||
return jsonify({
|
||||
"episodes": [dict(r) for r in rows],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/episodes/feeds")
|
||||
def list_episode_feeds():
|
||||
"""Return distinct feed names for filter dropdowns."""
|
||||
db = get_db()
|
||||
rows = db.execute(
|
||||
"SELECT DISTINCT feed_name FROM episodes ORDER BY feed_name"
|
||||
).fetchall()
|
||||
return jsonify([r["feed_name"] for r in rows])
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Feed management API — CRUD on feeds.yaml."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import yaml
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..config import FEEDS_CONFIG
|
||||
from ..db import get_db
|
||||
|
||||
bp = Blueprint("feeds", __name__)
|
||||
|
||||
|
||||
def _read_feeds():
|
||||
if not FEEDS_CONFIG.exists():
|
||||
return []
|
||||
with open(FEEDS_CONFIG) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return data.get("feeds", [])
|
||||
|
||||
|
||||
def _write_feeds(feeds):
|
||||
data = {"feeds": feeds}
|
||||
# Atomic write: temp file then rename
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=str(FEEDS_CONFIG.parent), suffix=".yaml.tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
os.replace(tmp_path, str(FEEDS_CONFIG))
|
||||
except Exception:
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
|
||||
|
||||
@bp.route("/feeds")
|
||||
def list_feeds():
|
||||
feeds = _read_feeds()
|
||||
db = get_db()
|
||||
|
||||
result = []
|
||||
for feed in feeds:
|
||||
name = feed.get("name", "")
|
||||
state = db.execute(
|
||||
"SELECT last_poll FROM feed_state WHERE feed_name=?", (name,)
|
||||
).fetchone()
|
||||
ep_count = db.execute(
|
||||
"SELECT COUNT(*) as c FROM episodes WHERE feed_name=?", (name,)
|
||||
).fetchone()["c"]
|
||||
|
||||
result.append({
|
||||
**feed,
|
||||
"last_poll": state["last_poll"] if state else None,
|
||||
"episode_count": ep_count,
|
||||
})
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.route("/feeds", methods=["POST"])
|
||||
def add_feed():
|
||||
data = request.get_json()
|
||||
if not data or not data.get("name") or not data.get("url"):
|
||||
return jsonify({"error": "name and url are required"}), 400
|
||||
|
||||
feeds = _read_feeds()
|
||||
|
||||
# Check for duplicate name
|
||||
for f in feeds:
|
||||
if f.get("name") == data["name"]:
|
||||
return jsonify({"error": "Feed with this name already exists"}), 409
|
||||
|
||||
new_feed = {
|
||||
"name": data["name"],
|
||||
"url": data["url"],
|
||||
"enabled": data.get("enabled", True),
|
||||
"priority": data.get("priority", 10),
|
||||
"max_episodes": data.get("max_episodes", 20),
|
||||
}
|
||||
|
||||
feeds.append(new_feed)
|
||||
_write_feeds(feeds)
|
||||
|
||||
return jsonify(new_feed), 201
|
||||
|
||||
|
||||
@bp.route("/feeds/<name>", methods=["PUT"])
|
||||
def update_feed(name):
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "JSON body required"}), 400
|
||||
|
||||
feeds = _read_feeds()
|
||||
found = False
|
||||
|
||||
for feed in feeds:
|
||||
if feed.get("name") == name:
|
||||
if "enabled" in data:
|
||||
feed["enabled"] = data["enabled"]
|
||||
if "url" in data:
|
||||
feed["url"] = data["url"]
|
||||
if "priority" in data:
|
||||
feed["priority"] = data["priority"]
|
||||
if "max_episodes" in data:
|
||||
feed["max_episodes"] = data["max_episodes"]
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
return jsonify({"error": "Feed not found"}), 404
|
||||
|
||||
_write_feeds(feeds)
|
||||
return jsonify({"status": "updated"})
|
||||
|
||||
|
||||
@bp.route("/feeds/<name>", methods=["DELETE"])
|
||||
def delete_feed(name):
|
||||
feeds = _read_feeds()
|
||||
new_feeds = [f for f in feeds if f.get("name") != name]
|
||||
|
||||
if len(new_feeds) == len(feeds):
|
||||
return jsonify({"error": "Feed not found"}), 404
|
||||
|
||||
_write_feeds(new_feeds)
|
||||
return jsonify({"status": "deleted"})
|
||||
@@ -0,0 +1,48 @@
|
||||
"""GET /api/logs — tail log files."""
|
||||
|
||||
import os
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..config import LOG_DIR
|
||||
|
||||
bp = Blueprint("logs", __name__)
|
||||
|
||||
ALLOWED_LOGS = {"poller", "liquidsoap", "icecast_access", "icecast_error"}
|
||||
|
||||
|
||||
@bp.route("/logs")
|
||||
def get_logs():
|
||||
log_name = request.args.get("file", "poller")
|
||||
lines_count = request.args.get("lines", 50, type=int)
|
||||
lines_count = min(lines_count, 500)
|
||||
|
||||
if log_name not in ALLOWED_LOGS:
|
||||
return jsonify({"error": f"Unknown log file. Allowed: {ALLOWED_LOGS}"}), 400
|
||||
|
||||
log_path = LOG_DIR / f"{log_name}.log"
|
||||
|
||||
if not log_path.exists():
|
||||
return jsonify({"lines": [], "file": log_name, "exists": False})
|
||||
|
||||
# Read last N lines efficiently
|
||||
try:
|
||||
with open(log_path, "rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
size = f.tell()
|
||||
# Read up to 1MB from the end
|
||||
read_size = min(size, 1024 * 1024)
|
||||
f.seek(max(0, size - read_size))
|
||||
content = f.read().decode("utf-8", errors="replace")
|
||||
|
||||
all_lines = content.splitlines()
|
||||
tail = all_lines[-lines_count:]
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
return jsonify({
|
||||
"lines": tail,
|
||||
"file": log_name,
|
||||
"exists": True,
|
||||
"total_lines": len(all_lines),
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Music library API — list dirs, list files, move files between folders."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..config import MUSIC_BASE, FALLBACK_DIR
|
||||
|
||||
bp = Blueprint("music", __name__)
|
||||
|
||||
AUDIO_EXTENSIONS = {".mp3", ".flac", ".ogg", ".opus", ".m4a", ".wav"}
|
||||
|
||||
|
||||
def _get_folders():
|
||||
"""Return dict mapping folder name -> absolute path."""
|
||||
folders = {}
|
||||
if MUSIC_BASE.exists():
|
||||
for d in sorted(MUSIC_BASE.iterdir()):
|
||||
if d.is_dir():
|
||||
folders[d.name] = d
|
||||
folders["fallback"] = FALLBACK_DIR
|
||||
return folders
|
||||
|
||||
|
||||
def _count_audio_files(directory):
|
||||
if not directory.exists():
|
||||
return 0
|
||||
return sum(
|
||||
1 for f in directory.rglob("*")
|
||||
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS
|
||||
)
|
||||
|
||||
|
||||
def _list_audio_files(directory):
|
||||
if not directory.exists():
|
||||
return []
|
||||
files = []
|
||||
for f in sorted(directory.iterdir()):
|
||||
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS:
|
||||
files.append({
|
||||
"name": f.name,
|
||||
"size": f.stat().st_size,
|
||||
})
|
||||
return files
|
||||
|
||||
|
||||
@bp.route("/music")
|
||||
def list_music():
|
||||
dirs = []
|
||||
if MUSIC_BASE.exists():
|
||||
for d in sorted(MUSIC_BASE.iterdir()):
|
||||
if d.is_dir():
|
||||
dirs.append({
|
||||
"folder": d.name,
|
||||
"path": str(d),
|
||||
"file_count": _count_audio_files(d),
|
||||
})
|
||||
|
||||
fallback_count = _count_audio_files(FALLBACK_DIR)
|
||||
|
||||
return jsonify({
|
||||
"directories": dirs,
|
||||
"fallback": {
|
||||
"path": str(FALLBACK_DIR),
|
||||
"file_count": fallback_count,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/music/<folder>/files")
|
||||
def list_files(folder):
|
||||
folders = _get_folders()
|
||||
if folder not in folders:
|
||||
return jsonify({"error": "Unknown folder"}), 404
|
||||
files = _list_audio_files(folders[folder])
|
||||
return jsonify({"folder": folder, "files": files})
|
||||
|
||||
|
||||
@bp.route("/music/move", methods=["POST"])
|
||||
def move_file():
|
||||
data = request.get_json()
|
||||
src_folder = data.get("from")
|
||||
dst_folder = data.get("to")
|
||||
filename = data.get("file")
|
||||
|
||||
if not all([src_folder, dst_folder, filename]):
|
||||
return jsonify({"error": "Missing from, to, or file"}), 400
|
||||
|
||||
folders = _get_folders()
|
||||
if src_folder not in folders or dst_folder not in folders:
|
||||
return jsonify({"error": "Unknown folder"}), 404
|
||||
|
||||
if os.sep in filename or "/" in filename or ".." in filename:
|
||||
return jsonify({"error": "Invalid filename"}), 400
|
||||
|
||||
src_path = folders[src_folder] / filename
|
||||
dst_path = folders[dst_folder] / filename
|
||||
|
||||
if not src_path.exists():
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
if dst_path.exists():
|
||||
return jsonify({"error": "File already exists in destination"}), 409
|
||||
|
||||
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(src_path), str(dst_path))
|
||||
return jsonify({"ok": True, "moved": filename, "from": src_folder, "to": dst_folder})
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Queue management API — GET/POST/DELETE /api/queue."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..config import QUEUE_DIR, PODCAST_DIR
|
||||
from ..db import get_db
|
||||
|
||||
bp = Blueprint("queue", __name__)
|
||||
|
||||
|
||||
@bp.route("/queue")
|
||||
def list_queue():
|
||||
db = get_db()
|
||||
rows = db.execute("""
|
||||
SELECT q.id, q.position, q.enqueued, q.played,
|
||||
e.id as episode_id, e.feed_name, e.title, e.pub_date, e.file_path
|
||||
FROM queue q
|
||||
JOIN episodes e ON e.id = q.episode_id
|
||||
WHERE q.played = 0
|
||||
ORDER BY q.position
|
||||
""").fetchall()
|
||||
|
||||
return jsonify([dict(r) for r in rows])
|
||||
|
||||
|
||||
@bp.route("/queue", methods=["POST"])
|
||||
def enqueue():
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "JSON body required"}), 400
|
||||
|
||||
episode_id = data.get("episode_id")
|
||||
file_path = data.get("file_path")
|
||||
|
||||
db = get_db()
|
||||
|
||||
if episode_id:
|
||||
row = db.execute(
|
||||
"SELECT * FROM episodes WHERE id=? AND downloaded=1", (episode_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({"error": "Episode not found or not downloaded"}), 404
|
||||
file_path = row["file_path"]
|
||||
elif file_path:
|
||||
if not os.path.exists(file_path):
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
else:
|
||||
return jsonify({"error": "Provide episode_id or file_path"}), 400
|
||||
|
||||
# Create queue entry if episode_id is available
|
||||
if episode_id:
|
||||
existing = db.execute(
|
||||
"SELECT 1 FROM queue WHERE episode_id=? AND played=0", (episode_id,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
return jsonify({"error": "Episode already in queue"}), 409
|
||||
|
||||
next_pos = db.execute(
|
||||
"SELECT COALESCE(MAX(position), 0) + 1 as p FROM queue"
|
||||
).fetchone()["p"]
|
||||
db.execute(
|
||||
"INSERT INTO queue (episode_id, position) VALUES (?, ?)",
|
||||
(episode_id, next_pos),
|
||||
)
|
||||
db.execute(
|
||||
"UPDATE episodes SET queued=1, played=0 WHERE id=?", (episode_id,)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Create symlink in queue directory
|
||||
QUEUE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
src = Path(file_path)
|
||||
eid = episode_id or 0
|
||||
link_name = f"{timestamp}_{eid:06d}_{src.name}"
|
||||
link_path = QUEUE_DIR / link_name
|
||||
|
||||
try:
|
||||
link_path.symlink_to(src.resolve())
|
||||
except (OSError, FileExistsError):
|
||||
try:
|
||||
link_path.hardlink_to(src.resolve())
|
||||
except (OSError, FileExistsError):
|
||||
shutil.copy2(str(src), str(link_path))
|
||||
|
||||
return jsonify({"status": "enqueued", "link": link_name}), 201
|
||||
|
||||
|
||||
@bp.route("/queue/<int:queue_id>", methods=["DELETE"])
|
||||
def dequeue(queue_id):
|
||||
db = get_db()
|
||||
|
||||
row = db.execute(
|
||||
"SELECT q.*, e.id as eid FROM queue q JOIN episodes e ON e.id=q.episode_id WHERE q.id=?",
|
||||
(queue_id,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "Queue item not found"}), 404
|
||||
|
||||
# Remove symlink from queue directory
|
||||
eid = row["eid"]
|
||||
if QUEUE_DIR.exists():
|
||||
pattern = re.compile(rf"_0*{eid}_")
|
||||
for item in QUEUE_DIR.iterdir():
|
||||
if pattern.search(item.name):
|
||||
item.unlink(missing_ok=True)
|
||||
break
|
||||
|
||||
db.execute("DELETE FROM queue WHERE id=?", (queue_id,))
|
||||
db.execute("UPDATE episodes SET queued=0 WHERE id=?", (eid,))
|
||||
db.commit()
|
||||
|
||||
return jsonify({"status": "removed"})
|
||||
@@ -0,0 +1,50 @@
|
||||
"""GET /api/schedule — schedule blocks with active indicator."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import yaml
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from ..config import STATION_CONFIG
|
||||
|
||||
bp = Blueprint("schedule", __name__)
|
||||
|
||||
DAY_NAMES = {1: "Mon", 2: "Tue", 3: "Wed", 4: "Thu", 5: "Fri", 6: "Sat", 7: "Sun"}
|
||||
|
||||
|
||||
@bp.route("/schedule")
|
||||
def get_schedule():
|
||||
try:
|
||||
with open(STATION_CONFIG) as f:
|
||||
config = yaml.safe_load(f)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
now = datetime.now()
|
||||
current_day = now.isoweekday()
|
||||
current_time = now.strftime("%H:%M")
|
||||
|
||||
blocks = []
|
||||
for block in config.get("schedule", []):
|
||||
days = block.get("days", [])
|
||||
start = block.get("start", "00:00")
|
||||
end = block.get("end", "23:59")
|
||||
|
||||
is_active = False
|
||||
if current_day in days:
|
||||
if start <= end:
|
||||
is_active = start <= current_time <= end
|
||||
else:
|
||||
is_active = current_time >= start or current_time <= end
|
||||
|
||||
blocks.append({
|
||||
"name": block.get("name"),
|
||||
"days": days,
|
||||
"day_names": [DAY_NAMES.get(d, str(d)) for d in days],
|
||||
"start": start,
|
||||
"end": end,
|
||||
"folder": block.get("folder"),
|
||||
"active": is_active,
|
||||
})
|
||||
|
||||
return jsonify(blocks)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""GET /api/status — stream status, now playing, queue depth, current schedule."""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import yaml
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from ..config import BASE_DIR, STATION_CONFIG
|
||||
from ..db import get_db
|
||||
|
||||
bp = Blueprint("status", __name__)
|
||||
|
||||
|
||||
def _get_current_schedule_block():
|
||||
"""Determine which schedule block is active right now."""
|
||||
try:
|
||||
with open(STATION_CONFIG) as f:
|
||||
config = yaml.safe_load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
now = datetime.now()
|
||||
current_day = now.isoweekday() # 1=Mon ... 7=Sun
|
||||
current_time = now.strftime("%H:%M")
|
||||
|
||||
for block in config.get("schedule", []):
|
||||
if current_day not in block.get("days", []):
|
||||
continue
|
||||
start = block.get("start", "00:00")
|
||||
end = block.get("end", "23:59")
|
||||
|
||||
# Handle overnight blocks (e.g., 18:00 - 06:00)
|
||||
if start <= end:
|
||||
if start <= current_time <= end:
|
||||
return block
|
||||
else:
|
||||
if current_time >= start or current_time <= end:
|
||||
return block
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_stream_status():
|
||||
"""Check if music files are available for streaming."""
|
||||
music_dir = os.path.join(BASE_DIR, "media", "music")
|
||||
fallback_dir = os.path.join(BASE_DIR, "media", "fallback")
|
||||
count = 0
|
||||
for d in (music_dir, fallback_dir):
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
for dirpath, _dirs, filenames in os.walk(d):
|
||||
count += sum(1 for f in filenames if f.lower().endswith(".mp3"))
|
||||
return {
|
||||
"online": count > 0,
|
||||
"tracks": count,
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/status")
|
||||
def status():
|
||||
db = get_db()
|
||||
|
||||
queue_depth = db.execute(
|
||||
"SELECT COUNT(*) as c FROM queue WHERE played=0"
|
||||
).fetchone()["c"]
|
||||
|
||||
total_episodes = db.execute(
|
||||
"SELECT COUNT(*) as c FROM episodes"
|
||||
).fetchone()["c"]
|
||||
|
||||
active_feeds = db.execute(
|
||||
"SELECT COUNT(*) as c FROM feed_state"
|
||||
).fetchone()["c"]
|
||||
|
||||
stream = _get_stream_status()
|
||||
block = _get_current_schedule_block()
|
||||
|
||||
return jsonify({
|
||||
"stream": stream,
|
||||
"queue_depth": queue_depth,
|
||||
"total_episodes": total_episodes,
|
||||
"active_feeds": active_feeds,
|
||||
"current_block": {
|
||||
"name": block["name"],
|
||||
"folder": block["folder"],
|
||||
} if block else None,
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Audio stream — serves individual tracks with seek support.
|
||||
|
||||
The frontend requests /api/stream/next to get the next track, then plays
|
||||
it via /api/stream/play/<id>. This gives the browser proper Content-Length
|
||||
and Range headers for seeking.
|
||||
"""
|
||||
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
|
||||
from ..config import BASE_DIR
|
||||
|
||||
bp = Blueprint("stream", __name__)
|
||||
|
||||
MUSIC_DIR = os.path.join(BASE_DIR, "media", "music")
|
||||
FALLBACK_DIR = os.path.join(BASE_DIR, "media", "fallback")
|
||||
QUEUE_DIR = os.path.join(BASE_DIR, "state", "queue")
|
||||
|
||||
# Playlist state — shuffled list with current index
|
||||
_playlist = []
|
||||
_playlist_index = 0
|
||||
|
||||
|
||||
def _find_mp3s():
|
||||
"""Collect all MP3 files from music and fallback dirs."""
|
||||
files = []
|
||||
for root_dir in (MUSIC_DIR, FALLBACK_DIR):
|
||||
if not os.path.isdir(root_dir):
|
||||
continue
|
||||
for dirpath, _dirs, filenames in os.walk(root_dir):
|
||||
for f in filenames:
|
||||
if f.lower().endswith(".mp3"):
|
||||
files.append(os.path.join(dirpath, f))
|
||||
return files
|
||||
|
||||
|
||||
def _find_queue_files():
|
||||
"""Collect queued podcast files (priority playback)."""
|
||||
if not os.path.isdir(QUEUE_DIR):
|
||||
return []
|
||||
files = []
|
||||
for f in sorted(os.listdir(QUEUE_DIR)):
|
||||
full = os.path.join(QUEUE_DIR, f)
|
||||
if os.path.isfile(full) and f.lower().endswith(".mp3"):
|
||||
files.append(full)
|
||||
return files
|
||||
|
||||
|
||||
def _title_from_path(path):
|
||||
"""Extract a display title from a file path."""
|
||||
name = os.path.splitext(os.path.basename(path))[0]
|
||||
# Strip queue prefix: 20260313_182201_000001_filename -> filename
|
||||
queue_match = re.match(r"^\d{8}_\d{6}_\d{6}_(.+)$", name)
|
||||
if queue_match:
|
||||
name = queue_match.group(1)
|
||||
# Strip leading track numbers like "01. " or "22. "
|
||||
if len(name) > 3 and name[0:2].isdigit() and name[2] in "._ ":
|
||||
name = name[3:].strip()
|
||||
elif len(name) > 4 and name[0:3].isdigit() and name[3] in "._ ":
|
||||
name = name[4:].strip()
|
||||
name = name.replace("_", " ").replace("-", " ")
|
||||
return name
|
||||
|
||||
|
||||
def _make_track_id(path):
|
||||
"""Create a safe ID from a path for URL use."""
|
||||
# Use path relative to BASE_DIR
|
||||
try:
|
||||
rel = os.path.relpath(path, BASE_DIR)
|
||||
except ValueError:
|
||||
rel = os.path.basename(path)
|
||||
return rel.replace("\\", "/")
|
||||
|
||||
|
||||
def _resolve_track_id(track_id):
|
||||
"""Resolve a track ID back to an absolute path, with safety checks."""
|
||||
# Prevent path traversal
|
||||
if ".." in track_id:
|
||||
return None
|
||||
path = os.path.normpath(os.path.join(BASE_DIR, track_id))
|
||||
# Must be under BASE_DIR
|
||||
if not path.startswith(os.path.normpath(str(BASE_DIR))):
|
||||
return None
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def _next_track():
|
||||
"""Get the next track to play. Queue first, then shuffled music."""
|
||||
global _playlist, _playlist_index
|
||||
|
||||
# Check podcast queue first
|
||||
queue_files = _find_queue_files()
|
||||
if queue_files:
|
||||
path = queue_files[0]
|
||||
return {
|
||||
"id": _make_track_id(path),
|
||||
"title": _title_from_path(path),
|
||||
"file": os.path.basename(path),
|
||||
"source": "queue",
|
||||
"size": os.path.getsize(path),
|
||||
}
|
||||
|
||||
# Shuffled music
|
||||
if not _playlist or _playlist_index >= len(_playlist):
|
||||
_playlist = _find_mp3s()
|
||||
if not _playlist:
|
||||
return None
|
||||
random.shuffle(_playlist)
|
||||
_playlist_index = 0
|
||||
|
||||
path = _playlist[_playlist_index]
|
||||
_playlist_index += 1
|
||||
|
||||
# Verify file still exists
|
||||
if not os.path.isfile(path):
|
||||
return _next_track()
|
||||
|
||||
return {
|
||||
"id": _make_track_id(path),
|
||||
"title": _title_from_path(path),
|
||||
"file": os.path.basename(path),
|
||||
"source": "music",
|
||||
"size": os.path.getsize(path),
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/stream/next")
|
||||
def next_track():
|
||||
"""Return metadata for the next track to play."""
|
||||
track = _next_track()
|
||||
if not track:
|
||||
return jsonify({"error": "No tracks available"}), 404
|
||||
return jsonify(track)
|
||||
|
||||
|
||||
@bp.route("/stream/play/<path:track_id>")
|
||||
def play_track(track_id):
|
||||
"""Serve an MP3 file with Range support for seeking."""
|
||||
path = _resolve_track_id(track_id)
|
||||
if not path:
|
||||
return jsonify({"error": "Track not found"}), 404
|
||||
return send_file(
|
||||
path,
|
||||
mimetype="audio/mpeg",
|
||||
conditional=True,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/stream/done", methods=["POST"])
|
||||
def mark_done():
|
||||
"""Remove a queue file after it finishes playing."""
|
||||
data = request.get_json() or {}
|
||||
track_id = data.get("id")
|
||||
if not track_id:
|
||||
return jsonify({"error": "Missing id"}), 400
|
||||
path = _resolve_track_id(track_id)
|
||||
if path and os.path.normpath(QUEUE_DIR) in os.path.normpath(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.route("/stream/skip", methods=["POST"])
|
||||
def skip():
|
||||
"""Skip the current queue item (user-initiated)."""
|
||||
queue_files = _find_queue_files()
|
||||
if queue_files:
|
||||
try:
|
||||
os.remove(queue_files[0])
|
||||
except OSError:
|
||||
pass
|
||||
return jsonify({"ok": True})
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"""Flask application for the Local Radio web dashboard."""
|
||||
|
||||
import os
|
||||
|
||||
from flask import Flask, send_from_directory
|
||||
|
||||
from .config import STATIC_DIR
|
||||
from .db import close_db
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__, static_folder=str(STATIC_DIR), static_url_path="")
|
||||
|
||||
# Register teardown
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
# Register API blueprints
|
||||
from .api import status_bp, queue_bp, episodes_bp, feeds_bp, schedule_bp, music_bp, logs_bp, stream_bp
|
||||
app.register_blueprint(status_bp, url_prefix="/api")
|
||||
app.register_blueprint(queue_bp, url_prefix="/api")
|
||||
app.register_blueprint(episodes_bp, url_prefix="/api")
|
||||
app.register_blueprint(feeds_bp, url_prefix="/api")
|
||||
app.register_blueprint(schedule_bp, url_prefix="/api")
|
||||
app.register_blueprint(music_bp, url_prefix="/api")
|
||||
app.register_blueprint(logs_bp, url_prefix="/api")
|
||||
app.register_blueprint(stream_bp, url_prefix="/api")
|
||||
|
||||
# SPA catch-all: serve index.html for any non-API route
|
||||
@app.route("/", defaults={"path": ""})
|
||||
@app.route("/<path:path>")
|
||||
def serve_spa(path):
|
||||
static = app.static_folder
|
||||
if static and path and os.path.exists(os.path.join(static, path)):
|
||||
return send_from_directory(static, path)
|
||||
if static and os.path.exists(os.path.join(static, "index.html")):
|
||||
return send_from_directory(static, "index.html")
|
||||
return "<h1>Local Radio</h1><p>Frontend not built. Run: cd web/frontend && npm run build</p>", 200
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Configuration for the Local Radio web UI."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# In dev mode, auto-detect base dir from project structure
|
||||
_default_base = "/opt/localradio"
|
||||
_project_root = Path(__file__).resolve().parent.parent
|
||||
if (_project_root / "config" / "station.yaml").exists():
|
||||
_default_base = str(_project_root)
|
||||
|
||||
BASE_DIR = Path(os.environ.get("LOCALRADIO_BASE", _default_base))
|
||||
|
||||
DB_PATH = BASE_DIR / "state" / "radio.db"
|
||||
QUEUE_DIR = BASE_DIR / "state" / "queue"
|
||||
FEEDS_CONFIG = BASE_DIR / "config" / "feeds.yaml"
|
||||
STATION_CONFIG = BASE_DIR / "config" / "station.yaml"
|
||||
MUSIC_BASE = BASE_DIR / "media" / "music"
|
||||
FALLBACK_DIR = BASE_DIR / "media" / "fallback"
|
||||
PODCAST_DIR = BASE_DIR / "media" / "podcasts"
|
||||
LOG_DIR = BASE_DIR / "logs"
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "frontend" / "dist"
|
||||
@@ -0,0 +1,23 @@
|
||||
"""SQLite database helpers for the web UI."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
from flask import g
|
||||
|
||||
from .config import DB_PATH
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
if "db" not in g:
|
||||
g.db = sqlite3.connect(str(DB_PATH), timeout=5)
|
||||
g.db.row_factory = sqlite3.Row
|
||||
g.db.execute("PRAGMA journal_mode=WAL")
|
||||
g.db.execute("PRAGMA foreign_keys=ON")
|
||||
g.db.execute("PRAGMA busy_timeout=5000")
|
||||
return g.db
|
||||
|
||||
|
||||
def close_db(e=None):
|
||||
db = g.pop("db", None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
@@ -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