const { useState, useEffect, useMemo, useRef, useCallback, createContext, useContext } = React;
/* =========================================================================
* Config
* ========================================================================= */
const CATEGORIES = [
{ key: "theme", label: "Themes",
col: "border-violet-200 bg-violet-50", head: "border-violet-200 bg-violet-100 text-violet-700" },
{ key: "holiday", label: "Holidays",
col: "border-amber-200 bg-amber-50", head: "border-amber-200 bg-amber-100 text-amber-700" },
{ key: "grade", label: "Grades",
col: "border-emerald-200 bg-emerald-50", head: "border-emerald-200 bg-emerald-100 text-emerald-700" },
];
const FILE_KINDS = [
{ key: "chord", label: "Chords", text: true },
{ key: "lyrics", label: "Lyrics", text: true },
{ key: "notated", label: "Notated Music", text: false },
{ key: "audio", label: "Audio", text: false },
{ key: "video", label: "Video", text: false },
{ key: "props", label: "Props", text: false },
{ key: "other", label: "Other", text: false },
];
/* =========================================================================
* Auth / API
* ========================================================================= */
const AuthContext = createContext(null);
const useAuth = () => useContext(AuthContext);
function makeToken(user, pass) {
return btoa(unescape(encodeURIComponent(user + ":" + pass)));
}
/* =========================================================================
* Icons
* ========================================================================= */
const PATHS = {
search: "M21 21l-4.3-4.3M11 19a8 8 0 100-16 8 8 0 000 16z",
plus: "M12 5v14M5 12h14",
trash: "M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m2 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6",
download: "M12 3v12m0 0l-4-4m4 4l4-4M4 21h16",
upload: "M12 21V9m0 0l-4 4m4-4l4 4M4 3h16",
edit: "M12 20h9M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4L16.5 3.5z",
close: "M18 6L6 18M6 6l12 12",
chevronDown: "M6 9l6 6 6-6",
chevronRight: "M9 6l6 6-6 6",
music: "M9 18V5l12-2v13M9 18a3 3 0 11-6 0 3 3 0 016 0zm12-2a3 3 0 11-6 0 3 3 0 016 0z",
film: "M23 7l-7 5 7 5V7zM14 5H3a2 2 0 00-2 2v10a2 2 0 002 2h11a2 2 0 002-2V7a2 2 0 00-2-2z",
doc: "M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zM14 2v6h6",
back: "M19 12H5M12 19l-7-7 7-7",
arrowUp: "M12 19V5M5 12l7-7 7 7",
arrowDown: "M12 5v14M19 12l-7 7-7-7",
tag: "M20.6 13.4L12 22l-9-9V4a1 1 0 011-1h9l7.6 7.6a2 2 0 010 2.8zM7 7h.01",
list: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01",
menu: "M4 6h16M4 12h16M4 18h16",
dots: "M12 5h.01M12 12h.01M12 19h.01",
layers: "M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5",
save: "M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2zM17 21v-8H7v8M7 3v5h8",
logout: "M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9",
play: "M5 3l14 9-14 9V3z",
refresh: "M23 4v6h-6M1 20v-6h6M3.5 9a9 9 0 0114.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0020.5 15",
check: "M20 6L9 17l-5-5",
grip: "M9 5h.01M9 12h.01M9 19h.01M15 5h.01M15 12h.01M15 19h.01",
clock: "M12 7v5l3 2M12 21a9 9 0 110-18 9 9 0 010 18z",
copy: "M9 9h9a2 2 0 012 2v9a2 2 0 01-2 2H9a2 2 0 01-2-2v-9a2 2 0 012-2zM5 15a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2",
restore: "M3 3v6h6M3.5 9a9 9 0 11-1.5 5",
printer: "M6 9V3h12v6M6 18H4a2 2 0 01-2-2v-4a2 2 0 012-2h16a2 2 0 012 2v4a2 2 0 01-2 2h-2M6 14h12v7H6z",
share: "M12 3v13M8 7l4-4 4 4M20 14v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5",
moon: "M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z",
sun: "M12 7a5 5 0 100 10 5 5 0 000-10zM12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42",
};
function Icon({ name, className = "w-4 h-4", stroke = 2 }) {
return (
);
}
/* Light/dark theme toggle. The initial class is set in index.html before paint;
this just flips it and remembers the choice. */
function ThemeToggle() {
const [dark, setDark] = useState(() =>
typeof document !== "undefined" && document.documentElement.classList.contains("dark"));
function toggle() {
const next = !dark;
setDark(next);
document.documentElement.classList.toggle("dark", next);
try { localStorage.setItem("theme", next ? "dark" : "light"); } catch (e) {}
}
return (
);
}
/* =========================================================================
* UI primitives
* ========================================================================= */
function Button({ variant = "default", size = "md", className = "", children, ...props }) {
const base = "inline-flex items-center justify-center gap-1.5 font-medium rounded-lg transition-all active:scale-[.98] disabled:opacity-50 disabled:pointer-events-none";
const sizes = { sm: "text-xs px-2.5 py-1.5", md: "text-sm px-3.5 py-2", icon: "p-2" };
const variants = {
default: "bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 hover:border-slate-300 shadow-sm",
primary: "bg-brand-600 text-white hover:bg-brand-700 shadow-sm shadow-brand-600/20",
ghost: "text-slate-500 hover:bg-slate-100 hover:text-slate-800",
danger: "text-red-600 hover:bg-red-50",
subtle: "bg-slate-100 text-slate-700 hover:bg-slate-200",
};
return (
);
}
function Field({ label, children }) {
return (
);
}
const inputCls =
"w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 placeholder-slate-400 outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-100";
function Modal({ open, onClose, children, size = "lg" }) {
useEffect(() => {
if (!open) return;
const h = (e) => e.key === "Escape" && onClose();
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [open, onClose]);
if (!open) return null;
const widths = { md: "max-w-lg", lg: "max-w-3xl", xl: "max-w-5xl" };
return (
{
const types = e.dataTransfer.types;
const isFile = types.includes("Files");
const isMove = types.includes("application/x-songster-file");
if (!isFile && !isMove) return;
e.preventDefault();
e.dataTransfer.dropEffect = isFile ? "copy" : "move";
if (!dragOver) setDragOver(true);
if (isFile !== dragUpload) setDragUpload(isFile);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) { setDragOver(false); setDragUpload(false); }
}}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
setDragUpload(false);
if (e.dataTransfer.files && e.dataTransfer.files.length) {
uploadFiles(e.dataTransfer.files);
} else {
const id = e.dataTransfer.getData("application/x-songster-file");
if (id) moveHere(id);
}
}}
>
{dragOver && dragUpload && (
Drop to upload to {meta.label}
)}
{/* Active / failed download jobs */}
{jobs.length > 0 && (
{jobs.map((j) => (
{j.title || j.url}
{j.status === "error" ? {j.message || "Download failed"}
: j.status === "queued" ? "Queued…"
: j.message ? `${j.message} ${j.progress || 0}%`
: `Downloading ${kind === "audio" ? "MP3" : "MP4"}… ${j.progress || 0}%`}
{(j.status === "error") && (
)}
{(j.status === "queued" || j.status === "running") &&
}
{(j.status === "running" || j.status === "queued") && (
)}
))}
)}
{!files.length && !jobs.length && (
No {meta.label.toLowerCase()} yet.
)}
{files.map((f) => (
{
e.dataTransfer.setData("application/x-songster-file", String(f.id));
e.dataTransfer.effectAllowed = "move";
}}>
{renaming === f.id ? (
setRenameVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
else if (e.key === "Escape") { renameCancel.current = true; setRenaming(null); }
}}
onBlur={() => { if (renameCancel.current) { renameCancel.current = false; return; } saveRename(f); }}
className="w-full rounded-md border border-brand-300 px-1.5 py-0.5 text-sm text-slate-800 outline-none focus:ring-2 focus:ring-brand-100" />
) : (
)}
{f.content_type === "text/html"
? "Word document · editable" + (f.edited ? " · edited" : "")
: f.is_text ? "Editable document" : (f.content_type || "file")}
{f.size ? " · " + humanSize(f.size) : ""}
{f.is_text && (
)}
{!f.is_text && (
)}
{moveFor === f.id && (
<>
setMoveFor(null)} />
Move to
{FILE_KINDS.filter((k) => k.key !== f.kind).map((k) => (
))}
>
)}
{kind === "audio" && !f.is_text && (
)}
{kind === "video" && !f.is_text && (
)}
))}
{previewFile && (
setPreviewFile(null)} onEdit={onEdit} />
)}
{historyFile && (
setHistoryFile(null)}
onRestored={() => { setHistoryFile(null); onChanged(); }} />
)}
del(confirmDel)} onClose={() => setConfirmDel(null)} />
setNewDocOpen(false)} size="md">
New {meta.label} document
Name it, then it opens in the editor.
setNewDocName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submitNewDoc(); }} />
setUrlOpen(false)} size="md">
Download {kind === "audio" ? "audio" : "video"} from a link
Paste a YouTube (or other supported site) URL. It downloads in the background and
{kind === "audio" ? " is saved as an MP3" : " is saved as an MP4"}.
setUrlVal(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submitUrl(); }} />
);
}
/* =========================================================================
* Song detail
* ========================================================================= */
function SongDetail({ songId, onBack, allTags, refreshTags, playlists, refreshPlaylists }) {
const { api, onlyoffice } = useAuth();
const toast = useToast();
const [song, setSong] = useState(null);
const [form, setForm] = useState(null);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [editingDoc, setEditingDoc] = useState(null);
const [confirmDel, setConfirmDel] = useState(false);
const [showPlaylists, setShowPlaylists] = useState(false);
const load = useCallback(async () => {
const s = await api(`/api/songs/${songId}`);
setSong(s);
setForm({
title: s.title, composer: s.composer, publication: s.publication,
text_source: s.text_source, notes: s.notes,
tag_ids: s.tags.map((t) => t.id),
});
setDirty(false);
}, [api, songId]);
useEffect(() => { load(); }, [load]);
function update(patch) {
setForm((f) => ({ ...f, ...patch }));
setDirty(true);
}
async function save() {
setSaving(true);
try {
await api(`/api/songs/${songId}`, { method: "PUT", body: form });
toast("Song saved", "success");
await load();
} finally {
setSaving(false);
}
}
async function del() {
await api(`/api/songs/${songId}`, { method: "DELETE", raw: true });
toast("Song deleted");
onBack();
}
async function addToPlaylist(pl) {
await api(`/api/playlists/${pl.id}/songs/${songId}`, { method: "POST" });
toast(`Added to “${pl.name}”`, "success");
refreshPlaylists();
setShowPlaylists(false);
}
if (!song || !form) {
return
;
}
return (
{/* Header */}
{showPlaylists && (
{!playlists.length &&
No playlists yet
}
{playlists.map((p) => (
))}
)}
{/* Left: metadata */}
{/* Right: files */}
{FILE_KINDS.map((k) => (
))}
{editingDoc && (
onlyoffice && onlyoffice.enabled ? (
setEditingDoc(null)} onSaved={() => { setEditingDoc(null); load(); }} />
) : (
setEditingDoc(null)} size="md">
Editor unavailable
The document editor (OnlyOffice) isn’t running. Start it with docker compose up -d, or download the file to edit it.
)
)}
setConfirmDel(false)} />
);
}
/* =========================================================================
* Playlists
* ========================================================================= */
function PlaylistsView({ onOpen, playlists, refreshPlaylists }) {
const { api } = useAuth();
const toast = useToast();
const [name, setName] = useState("");
const [confirmDel, setConfirmDel] = useState(null);
async function create(e) {
e.preventDefault();
if (!name.trim()) return;
const pl = await api(`/api/playlists`, { method: "POST", body: { name: name.trim() } });
setName("");
refreshPlaylists();
onOpen(pl.id);
}
async function del(pl) {
await api(`/api/playlists/${pl.id}`, { method: "DELETE", raw: true });
toast("Playlist deleted");
refreshPlaylists();
}
return (
Playlists
{!playlists.length ? (
No playlists yet — create one above.
) : (
{playlists.map((p) => (
onOpen(p.id)}
className="group cursor-pointer rounded-xl border border-slate-200 bg-white p-4 transition hover:border-brand-300 hover:shadow-md">
{p.name}
{p.item_count} song{p.item_count === 1 ? "" : "s"}
))}
)}
del(confirmDel)} onClose={() => setConfirmDel(null)} />
);
}
function PlaylistDetail({ playlistId, onBack, onOpenSong, allTags = [], onGoSonglists, refreshPlaylists }) {
const { api } = useAuth();
const toast = useToast();
const [pl, setPl] = useState(null);
const [adding, setAdding] = useState(false);
const [genOpen, setGenOpen] = useState(false);
const [genName, setGenName] = useState("");
const [genBusy, setGenBusy] = useState(false);
const [renaming, setRenaming] = useState(false);
const [nameVal, setNameVal] = useState("");
const renameCancel = useRef(false);
const [q, setQ] = useState("");
const [selectedTags, setSelectedTags] = useState([]);
const [letter, setLetter] = useState("");
const [results, setResults] = useState([]);
const load = useCallback(async () => {
setPl(await api(`/api/playlists/${playlistId}`));
refreshPlaylists && refreshPlaylists(); // keep the Shell's playlist list (name/count) in sync
}, [api, playlistId, refreshPlaylists]);
useEffect(() => { load(); }, [load]);
function toggleTag(id) {
setSelectedTags((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
}
const hasFilters = q || selectedTags.length > 0 || letter;
function clearFilters() { setQ(""); setSelectedTags([]); setLetter(""); }
useEffect(() => {
if (!adding) return;
const t = setTimeout(async () => {
const params = new URLSearchParams({ page_size: "50" });
if (q) params.set("q", q);
if (letter) params.set("letter", letter);
if (selectedTags.length) params.set("tag_ids", selectedTags.join(","));
const res = await api(`/api/songs?${params}`);
setResults(res.items);
}, 200);
return () => clearTimeout(t);
}, [q, letter, selectedTags, adding, api]);
async function saveOrder(items) {
await api(`/api/playlists/${playlistId}`, {
method: "PUT",
body: { song_ids: items.map((i) => i.song.id) },
});
load();
}
function move(idx, dir) {
const items = [...pl.items];
const j = idx + dir;
if (j < 0 || j >= items.length) return;
[items[idx], items[j]] = [items[j], items[idx]];
setPl({ ...pl, items });
saveOrder(items);
}
async function remove(item) {
const items = pl.items.filter((i) => i.id !== item.id);
setPl({ ...pl, items });
await saveOrder(items);
}
async function add(song) {
await api(`/api/playlists/${playlistId}/songs/${song.id}`, { method: "POST" });
toast("Added", "success");
load();
}
async function saveName() {
const name = nameVal.trim();
setRenaming(false);
if (!name || name === pl.name) return;
await api(`/api/playlists/${playlistId}`, { method: "PUT", body: { name } });
toast("Renamed", "success");
load();
}
async function generate() {
const name = genName.trim();
if (!name) return;
setGenBusy(true);
try {
const f = await api(`/api/playlists/${playlistId}/songlist`, { method: "POST", body: { name } });
setGenOpen(false);
toast(`Created “${f.name}” in SongLists`, "success");
onGoSonglists && onGoSonglists();
} catch (e) {
toast(e.message || "Couldn’t generate the SongList", "error");
} finally {
setGenBusy(false);
}
}
if (!pl) return
;
const inList = new Set(pl.items.map((i) => i.song.id));
return (
{renaming ? (
setNameVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
else if (e.key === "Escape") { renameCancel.current = true; setRenaming(false); }
}}
onBlur={() => { if (renameCancel.current) { renameCancel.current = false; return; } saveName(); }}
className="w-full max-w-md rounded-md border border-brand-300 px-2 py-0.5 text-xl font-bold text-slate-800 outline-none focus:ring-2 focus:ring-brand-100" />
) : (
{pl.name}
)}
{pl.items.length} song{pl.items.length === 1 ? "" : "s"}
{!pl.items.length ? (
Empty playlist — add songs.
) : (
{pl.items.map((item, idx) => (
{idx + 1}
onOpenSong(item.song.id)}>
{item.song.title}
{item.song.composer &&
{item.song.composer}
}
{item.song.tags && item.song.tags.length > 0 && (
{item.song.tags.map((t) => )}
)}
))}
)}
setAdding(false)} size="xl">
{!results.length &&
No songs match these filters.
}
{results.map((s) => (
{s.title}
{s.composer &&
{s.composer}
}
{s.tags && s.tags.length > 0 && (
{s.tags.map((t) => )}
)}
{inList.has(s.id) ? (
In list
) : (
)}
))}
setGenOpen(false)} size="md">
Generate a SongList
Combines each song’s chord document — in this playlist’s order — into one document, saved under SongLists. Songs without a chord doc are skipped.
setGenName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") generate(); }} />
);
}
/* =========================================================================
* SongLists — setlist documents generated from playlists
* ========================================================================= */
function SongListsView() {
const { api, onlyoffice } = useAuth();
const toast = useToast();
const [lists, setLists] = useState(null);
const [editingDoc, setEditingDoc] = useState(null);
const [previewFile, setPreviewFile] = useState(null);
const [renaming, setRenaming] = useState(null);
const [renameVal, setRenameVal] = useState("");
const renameCancel = useRef(false);
const [confirmDel, setConfirmDel] = useState(null);
const load = useCallback(async () => { setLists(await api(`/api/songlists`)); }, [api]);
useEffect(() => { load(); }, [load]);
async function saveRename(f) {
const name = renameVal.trim();
setRenaming(null);
if (!name || name === f.name) return;
await api(`/api/files/${f.id}`, { method: "PUT", body: { name } });
toast("Renamed", "success");
load();
}
async function del(f) {
await api(`/api/files/${f.id}`, { method: "DELETE", raw: true });
toast("Deleted");
load();
}
return (
SongLists
Setlists generated from your playlists
{lists === null ? (
) : !lists.length ? (
No song lists yet.
Open a playlist and use Generate SongList.
) : (
{lists.map((f) => (
{renaming === f.id ? (
setRenameVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); e.target.blur(); }
else if (e.key === "Escape") { renameCancel.current = true; setRenaming(null); }
}}
onBlur={() => { if (renameCancel.current) { renameCancel.current = false; return; } saveRename(f); }}
className="w-full rounded-md border border-brand-300 px-1.5 py-0.5 text-sm text-slate-800 outline-none focus:ring-2 focus:ring-brand-100" />
) : (
)}
{humanSize(f.size)}{f.edited ? " · edited" : ""}
))}
)}
{previewFile &&
setPreviewFile(null)} onEdit={setEditingDoc} />}
{editingDoc && (
onlyoffice && onlyoffice.enabled ? (
setEditingDoc(null)} onSaved={() => { setEditingDoc(null); load(); }} />
) : (
setEditingDoc(null)} size="md">
Editor unavailable
OnlyOffice isn’t running. Download the file to edit it.
)
)}
del(confirmDel)} onClose={() => setConfirmDel(null)} />
);
}
/* =========================================================================
* Tags management
* ========================================================================= */
function TagsView({ allTags, refreshTags }) {
const { api } = useAuth();
const toast = useToast();
const [confirmDel, setConfirmDel] = useState(null);
const [drafts, setDrafts] = useState({});
const [lists, setLists] = useState({}); // category -> ordered tag array (local, for smooth DnD)
const [dragOver, setDragOver] = useState(null); // { cat, index }
const dragItem = useRef(null); // { cat, index }
useEffect(() => {
const next = {};
CATEGORIES.forEach((c) => (next[c.key] = allTags.filter((t) => t.category === c.key)));
setLists(next);
}, [allTags]);
async function add(cat) {
const name = (drafts[cat] || "").trim();
if (!name) return;
await api(`/api/tags`, { method: "POST", body: { name, category: cat } });
setDrafts((d) => ({ ...d, [cat]: "" }));
refreshTags();
}
async function del(tag) {
await api(`/api/tags/${tag.id}`, { method: "DELETE", raw: true });
toast("Tag deleted");
refreshTags();
}
function persistOrder(cat, list) {
api(`/api/tags/order`, { method: "PUT", body: { category: cat, ordered_ids: list.map((t) => t.id) } })
.then(refreshTags)
.catch((e) => { toast(e.message || "Couldn't save order", "error"); refreshTags(); });
}
function onDrop(cat, index) {
const from = dragItem.current;
dragItem.current = null;
setDragOver(null);
if (!from || from.cat !== cat || from.index === index) return;
const list = [...(lists[cat] || [])];
const [moved] = list.splice(from.index, 1);
list.splice(index > from.index ? index - 1 : index, 0, moved);
setLists((l) => ({ ...l, [cat]: list }));
persistOrder(cat, list);
}
return (
Tags
Manage the Themes, Holidays and Grades used to organize songs. Drag to reorder — the order shows everywhere you tag & filter.
{CATEGORIES.map((cat) => {
const tags = lists[cat.key] || [];
return (
{cat.label}
{tags.length}
setDrafts((d) => ({ ...d, [cat.key]: e.target.value }))}
onKeyDown={(e) => e.key === "Enter" && add(cat.key)} />
{ e.preventDefault(); if (dragItem.current?.cat === cat.key) setDragOver({ cat: cat.key, index: tags.length }); }}
onDrop={() => onDrop(cat.key, tags.length)}>
{tags.map((t, index) => {
const isDragging = dragItem.current?.cat === cat.key && dragItem.current?.index === index;
const showLine = dragOver && dragOver.cat === cat.key && dragOver.index === index;
return (
{ dragItem.current = { cat: cat.key, index }; e.dataTransfer.effectAllowed = "move"; }}
onDragEnd={() => { dragItem.current = null; setDragOver(null); }}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); if (dragItem.current?.cat === cat.key) setDragOver({ cat: cat.key, index }); }}
onDrop={(e) => { e.stopPropagation(); onDrop(cat.key, index); }}
className={`group flex items-center gap-2 px-2 py-2 ${showLine ? "border-t-2 border-brand-400" : "border-t-2 border-transparent"} ${isDragging ? "opacity-40" : "hover:bg-slate-50"}`}>
{t.name}
);
})}
{!tags.length &&
No tags yet
}
);
})}
del(confirmDel)} onClose={() => setConfirmDel(null)} />
);
}
/* =========================================================================
* App shell
* ========================================================================= */
function NavItem({ icon, label, active, onClick }) {
return (
);
}
function Shell({ username, onLogout }) {
const { api } = useAuth();
const [route, setRoute] = useState({ name: "songs" });
const [allTags, setAllTags] = useState([]);
const [playlists, setPlaylists] = useState([]);
const refreshTags = useCallback(async () => {
try { setAllTags(await api(`/api/tags`)); } catch {}
}, [api]);
const refreshPlaylists = useCallback(async () => {
try { setPlaylists(await api(`/api/playlists`)); } catch {}
}, [api]);
useEffect(() => { refreshTags(); refreshPlaylists(); }, [refreshTags, refreshPlaylists]);
const [drawer, setDrawer] = useState(false);
const nav = (name) => setRoute({ name });
return (
{/* Desktop sidebar */}
{/* Mobile drawer */}
{drawer && (
setDrawer(false)}>
)}
{/* Mobile top bar */}
{/* Main */}
{route.name === "songs" && (
setRoute({ name: "song", id })} allTags={allTags} refreshTags={refreshTags} />
)}
{route.name === "song" && (
nav("songs")} allTags={allTags} refreshTags={refreshTags}
playlists={playlists} refreshPlaylists={refreshPlaylists} />
)}
{route.name === "playlists" && (
setRoute({ name: "playlist", id })} playlists={playlists} refreshPlaylists={refreshPlaylists} />
)}
{route.name === "playlist" && (
nav("playlists")}
onOpenSong={(id) => setRoute({ name: "song", id })} allTags={allTags}
onGoSonglists={() => nav("songlists")} refreshPlaylists={refreshPlaylists} />
)}
{route.name === "songlists" && }
{route.name === "tags" && }
);
}
function SidebarContent({ route, nav, username, onLogout, onNavigate }) {
const go = (name) => { nav(name); onNavigate && onNavigate(); };
return (
<>
{username.slice(0, 2).toUpperCase()}
{username}
>
);
}
/* =========================================================================
* Root
* ========================================================================= */
function App() {
const [token, setToken] = useState(() => localStorage.getItem("songster_token") || "");
const [username, setUsername] = useState(() => localStorage.getItem("songster_user") || "");
const logout = useCallback(() => {
localStorage.removeItem("songster_token");
localStorage.removeItem("songster_user");
setToken("");
setUsername("");
}, []);
const request = useCallback(async (path, opts = {}) => {
const headers = { Authorization: "Basic " + token, ...(opts.headers || {}) };
let body = opts.body;
if (body !== undefined && !opts.form) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(body);
}
const res = await fetch(path, { method: opts.method || "GET", headers, body });
if (res.status === 401) { logout(); throw new Error("Session expired"); }
if (!res.ok) {
let detail = res.statusText;
try { detail = (await res.json()).detail || detail; } catch {}
throw new Error(detail);
}
if (opts.raw || res.status === 204) return null;
return res.json();
}, [token, logout]);
const mediaUrl = useCallback((fileId, download, format) =>
`/api/files/${fileId}/raw?token=${encodeURIComponent(token)}` +
(download ? "&download=true" : "") + (format ? "&format=" + format : ""),
[token]);
const [appConfig, setAppConfig] = useState(null);
useEffect(() => {
if (!token) { setAppConfig(null); return; }
request("/api/config").then(setAppConfig).catch(() => setAppConfig({ onlyoffice: { enabled: false } }));
}, [token, request]);
function onLogin(tok, name) {
localStorage.setItem("songster_token", tok);
localStorage.setItem("songster_user", name);
setToken(tok);
setUsername(name);
}
if (!token) return
;
return (
);
}
ReactDOM.createRoot(document.getElementById("root")).render(
);