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 (
e.stopPropagation()}> {children}
); } /* Toast system */ const ToastContext = createContext(() => {}); const useToast = () => useContext(ToastContext); function ToastHost({ children }) { const [toasts, setToasts] = useState([]); const idRef = useRef(0); const push = useCallback((msg, type = "info") => { const id = ++idRef.current; setToasts((t) => [...t, { id, msg, type }]); setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3200); }, []); return ( {children}
{toasts.map((t) => (
{t.msg}
))}
); } function Confirm({ open, title, message, onConfirm, onClose, confirmLabel = "Delete", danger = true }) { return (

{title}

{message}

); } function Spinner({ className = "w-5 h-5" }) { return ( ); } /* ========================================================================= * Login * ========================================================================= */ function Login({ onLogin }) { const [user, setUser] = useState(""); const [pass, setPass] = useState(""); const [err, setErr] = useState(""); const [busy, setBusy] = useState(false); async function submit(e) { e.preventDefault(); setBusy(true); setErr(""); const token = makeToken(user, pass); try { const res = await fetch("/api/me", { headers: { Authorization: "Basic " + token } }); if (!res.ok) throw new Error("Invalid username or password"); const data = await res.json(); onLogin(token, data.username); } catch (e) { setErr(e.message || "Login failed"); } finally { setBusy(false); } } return (

Songster

Your song library & playlist manager

{err &&
{err}
}
setUser(e.target.value)} /> setPass(e.target.value)} />
); } /* ========================================================================= * Tag chips / pickers * ========================================================================= */ const CAT_COLORS = { theme: "bg-brand-50 text-brand-700 ring-brand-200", holiday: "bg-amber-50 text-amber-700 ring-amber-200", grade: "bg-emerald-50 text-emerald-700 ring-emerald-200", }; function TagChip({ tag, onRemove }) { return ( {tag.name} {onRemove && ( )} ); } /* Multi-select tag editor for one category */ function TagPicker({ category, label, allTags, selectedIds, onChange, api, onTagsChanged }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(""); const ref = useRef(null); const cats = allTags.filter((t) => t.category === category); const selected = cats.filter((t) => selectedIds.includes(t.id)); useEffect(() => { const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, []); const filtered = cats.filter((t) => t.name.toLowerCase().includes(q.toLowerCase())); const exactMatch = cats.some((t) => t.name.toLowerCase() === q.trim().toLowerCase()); async function createAndAdd() { const name = q.trim(); if (!name) return; const tag = await api(`/api/tags`, { method: "POST", body: { name, category } }); onTagsChanged(); onChange([...selectedIds, tag.id]); setQ(""); } function toggle(id) { onChange(selectedIds.includes(id) ? selectedIds.filter((x) => x !== id) : [...selectedIds, id]); } return (
{label}
setOpen(true)}> {selected.map((t) => ( toggle(t.id)} /> ))} setOpen(true)} onChange={(e) => { setQ(e.target.value); setOpen(true); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (!exactMatch && q.trim()) createAndAdd(); } }} />
{open && (
{filtered.map((t) => ( ))} {q.trim() && !exactMatch && ( )} {!filtered.length && !q.trim() &&
No tags yet
}
)}
); } /* Reusable tag-filter columns + A-Z, shared by the songs list and the playlist "add songs" picker. Parent owns the state. */ function TagFilters({ allTags, selectedTags, toggleTag, letter, setLetter, columnHeight = "h-60" }) { return (
{CATEGORIES.map((cat) => { const cats = allTags.filter((t) => t.category === cat.key); if (!cats.length) return null; return (
{cat.label}
{cats.map((t) => { const on = selectedTags.includes(t.id); return ( ); })}
); })}
{LETTERS.map((l) => ( ))}
); } /* ========================================================================= * Songs list view * ========================================================================= */ const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""); function SongsView({ onOpen, allTags, refreshTags }) { const { api } = useAuth(); const toast = useToast(); const [data, setData] = useState({ items: [], total: 0 }); const [loading, setLoading] = useState(true); const [q, setQ] = useState(""); const [debouncedQ, setDebouncedQ] = useState(""); const [selectedTags, setSelectedTags] = useState([]); const [sort, setSort] = useState("title"); const [order, setOrder] = useState("asc"); const [letter, setLetter] = useState(""); const [page, setPage] = useState(1); const [creating, setCreating] = useState(false); const [showFilters, setShowFilters] = useState(false); // mobile filter panel const pageSize = 50; useEffect(() => { const t = setTimeout(() => setDebouncedQ(q), 250); return () => clearTimeout(t); }, [q]); useEffect(() => { setPage(1); }, [debouncedQ, selectedTags, sort, order, letter]); const load = useCallback(async () => { setLoading(true); const params = new URLSearchParams({ sort, order, page: String(page), page_size: String(pageSize) }); if (debouncedQ) params.set("q", debouncedQ); if (letter) params.set("letter", letter); if (selectedTags.length) params.set("tag_ids", selectedTags.join(",")); try { const res = await api(`/api/songs?${params}`); setData(res); } finally { setLoading(false); } }, [api, sort, order, page, debouncedQ, letter, selectedTags]); useEffect(() => { load(); }, [load]); async function createSong() { setCreating(true); try { const song = await api(`/api/songs`, { method: "POST", body: { title: "Untitled song" } }); onOpen(song.id); } finally { setCreating(false); } } const totalPages = Math.max(1, Math.ceil(data.total / pageSize)); 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(""); } return (
{/* Toolbar */}

Songs

{data.total.toLocaleString()} songs in your library

setQ(e.target.value)} />
{/* Mobile filter toggle */}
{/* Filters (tags + A-Z): collapsible on mobile, always shown on desktop */}
{/* /filters */}
{/* Table */}
{loading && (
Loading
)} {!loading && !data.items.length ? (

No songs match your filters.

) : ( {data.items.map((s) => ( onOpen(s.id)} className="cursor-pointer border-b border-slate-200 hover:bg-brand-50/60"> ))}
Title Composer Publication Tags
{s.title}
{/* On phones, fold composer + tag count under the title */}
{s.composer && {s.composer}} {s.tags.length > 0 && {s.tags.length} tag{s.tags.length > 1 ? "s" : ""}}
{s.composer} {s.publication}
{s.tags.slice(0, 4).map((t) => )} {s.tags.length > 4 && +{s.tags.length - 4}}
)}
{/* Pagination */}
Page {data.page} of {totalPages}
); } /* ========================================================================= * OnlyOffice embedded editor (full-fidelity Word editing) * ========================================================================= */ const _scriptCache = {}; function loadScript(src) { if (_scriptCache[src]) return _scriptCache[src]; _scriptCache[src] = new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = src; s.onload = resolve; s.onerror = () => reject(new Error("Failed to load " + src)); document.body.appendChild(s); }); return _scriptCache[src]; } function OnlyOfficeEditor({ file, docServerUrl, onClose, onSaved }) { const { api } = useAuth(); const toast = useToast(); const holderId = "oo-holder-" + file.id; const instRef = useRef(null); const pendingSaveAs = useRef(null); // {name, format, kind} awaiting onDownloadAs const [status, setStatus] = useState("loading"); // loading | ready | error const [errMsg, setErrMsg] = useState(""); const [saveAsOpen, setSaveAsOpen] = useState(false); const [saveAsBusy, setSaveAsBusy] = useState(false); const [saveAsName, setSaveAsName] = useState(""); const [saveAsFormat, setSaveAsFormat] = useState("docx"); const [saveAsKind, setSaveAsKind] = useState(file.kind); function openSaveAs() { setSaveAsName((file.name || "document").replace(/\.[^.]+$/, "")); setSaveAsFormat("docx"); setSaveAsKind(file.kind); setSaveAsOpen(true); } // Save As exports the *live* document via the editor's downloadAs (so unsaved // edits are included and the format conversion is done client-side). The result // URL comes back through the onDownloadAs event, which finishes the save. function submitSaveAs() { const name = saveAsName.trim(); if (!name || !instRef.current) return; pendingSaveAs.current = { name, format: saveAsFormat, kind: saveAsKind }; setSaveAsBusy(true); setSaveAsOpen(false); try { instRef.current.downloadAs(saveAsFormat); } catch (e) { pendingSaveAs.current = null; setSaveAsBusy(false); toast("Couldn’t start the export", "error"); } } function onExported(event) { const pend = pendingSaveAs.current; if (!pend) return; // a plain download, not our Save As pendingSaveAs.current = null; const d = event && event.data; const url = (d && d.url) || (typeof d === "string" ? d : null); const fileType = (d && d.fileType) || pend.format; if (!url) { setSaveAsBusy(false); toast("Export failed", "error"); return; } api(`/api/files/${file.id}/save-as`, { method: "POST", body: { name: pend.name, format: pend.format, kind: pend.kind, url, fileType }, }) .then((created) => toast(`Saved as “${created.name}”`, "success")) .catch((e) => toast("Couldn’t save a copy — " + (e.message || "try again"), "error")) .finally(() => setSaveAsBusy(false)); } // Close just dismisses the editor. Saving is done inside OnlyOffice (Ctrl/Cmd+S). function close() { try { instRef.current && instRef.current.destroyEditor(); } catch (e) {} instRef.current = null; onClose(); setTimeout(() => onSaved && onSaved(), 400); } const deadRef = useRef(false); function reinit() { // rebuild the editor (after closing history or restoring a version) try { instRef.current && instRef.current.destroyEditor(); } catch (e) {} instRef.current = null; setTimeout(() => { if (!deadRef.current) initEditor(); }, 300); } async function initEditor() { setStatus("loading"); try { await loadScript(`${docServerUrl}/web-apps/apps/api/documents/api.js`); if (deadRef.current) return; if (!window.DocsAPI) throw new Error("OnlyOffice API did not load"); const { config } = await api(`/api/files/${file.id}/onlyoffice-config`); if (deadRef.current) return; instRef.current = new window.DocsAPI.DocEditor(holderId, { ...config, width: "100%", height: "100%", type: window.innerWidth < 768 ? "mobile" : "desktop", events: { onAppReady: () => setStatus("ready"), onError: (e) => { setStatus("error"); setErrMsg((e && e.data) || "Editor error"); }, onRequestClose: () => close(), onDownloadAs: (e) => onExported(e), // native version history, backed by Songster's file_versions onRequestHistory: async () => { try { instRef.current.refreshHistory(await api(`/api/files/${file.id}/oo-history`)); } catch (e) {} }, onRequestHistoryData: async (ev) => { try { instRef.current.setHistoryData(await api(`/api/files/${file.id}/oo-history/${ev.data}`)); } catch (e) {} }, onRequestHistoryClose: () => reinit(), onRequestRestore: async (ev) => { const v = ev && ev.data && (ev.data.version != null ? ev.data.version : ev.data); try { await api(`/api/files/${file.id}/oo-history/${v}/restore`, { method: "POST" }); toast("Version restored", "success"); reinit(); } catch (e) { toast("Couldn’t restore that version", "error"); } }, }, }); } catch (e) { if (!deadRef.current) { setStatus("error"); setErrMsg(e.message || String(e)); } } } useEffect(() => { deadRef.current = false; initEditor(); return () => { deadRef.current = true; try { instRef.current && instRef.current.destroyEditor(); } catch (e) {} }; }, []); return (

{file.name} OnlyOffice

{status === "loading" && Loading…}
Save your changes in the editor (⌘S / Ctrl+S) before closing — closing does not save.
{status === "error" && (

Couldn’t load the OnlyOffice editor.

{errMsg}

)}
setSaveAsOpen(false)} size="md">

Save a copy

Creates a new, separate file — the original stays as it is.

setSaveAsName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submitSaveAs(); }} />

Includes your current edits (even unsaved ones). Non-Word formats are converted by OnlyOffice, with custom fonts preserved.

); } /* Read-only OnlyOffice viewer (used inside the file preview). */ function OnlyOfficeView({ file, docServerUrl }) { const { api } = useAuth(); const holderId = "oo-view-" + file.id; const instRef = useRef(null); const [status, setStatus] = useState("loading"); const [errMsg, setErrMsg] = useState(""); useEffect(() => { let dead = false; (async () => { try { await loadScript(`${docServerUrl}/web-apps/apps/api/documents/api.js`); if (dead) return; if (!window.DocsAPI) throw new Error("OnlyOffice API did not load"); const { config } = await api(`/api/files/${file.id}/onlyoffice-config?mode=view`); if (dead) return; instRef.current = new window.DocsAPI.DocEditor(holderId, { ...config, width: "100%", height: "100%", type: window.innerWidth < 768 ? "mobile" : "desktop", events: { onAppReady: () => setStatus("ready"), onError: (e) => { setStatus("error"); setErrMsg((e && e.data) || "Viewer error"); }, }, }); } catch (e) { if (!dead) { setStatus("error"); setErrMsg(e.message || String(e)); } } })(); return () => { dead = true; try { instRef.current && instRef.current.destroyEditor(); } catch (e) {} }; }, []); return (
{status === "loading" &&
} {status === "error" &&
Couldn’t load the viewer: {errMsg}
}
); } /* ========================================================================= * Version history * ========================================================================= */ const VERSION_SOURCE = { edit: "Edited", replace: "Replaced", "pre-revert": "Before revert", import: "As imported", }; function relTime(iso) { if (!iso) return ""; const d = new Date(iso.endsWith("Z") ? iso : iso + "Z"); // server timestamps are UTC const diff = (Date.now() - d.getTime()) / 1000; if (diff < 60) return "just now"; if (diff < 3600) return Math.floor(diff / 60) + "m ago"; if (diff < 86400) return Math.floor(diff / 3600) + "h ago"; if (diff < 604800) return Math.floor(diff / 86400) + "d ago"; return d.toLocaleDateString(); } function VersionHistory({ file, onClose, onRestored }) { const { api, token } = useAuth(); const toast = useToast(); const [versions, setVersions] = useState(null); const [sel, setSel] = useState(null); const [preview, setPreview] = useState(null); const [confirmRestore, setConfirmRestore] = useState(null); const [busy, setBusy] = useState(false); const load = useCallback(async () => { setVersions(await api(`/api/files/${file.id}/versions`)); }, [api, file.id]); useEffect(() => { load(); }, [load]); async function openPreview(v) { setSel(v.id); setPreview(null); if (v.is_text) { const res = await fetch(`/api/files/${file.id}/versions/${v.id}/raw`, { headers: { Authorization: "Basic " + token }, }); setPreview({ type: v.content_type === "text/html" ? "html" : "text", body: await res.text() }); } else { setPreview({ type: "binary" }); } } async function restore(v) { setBusy(true); try { const updated = await api(`/api/files/${file.id}/revert/${v.id}`, { method: "POST" }); toast("Restored earlier version", "success"); if (onRestored) onRestored(updated); load(); } finally { setBusy(false); } } const dlUrl = (v) => `/api/files/${file.id}/versions/${v.id}/raw?token=${encodeURIComponent(token)}&download=true`; return (

Version history — {file.name}

{/* version list */}
{!versions &&
} {versions && !versions.length && (

No earlier versions yet. They appear here after you save changes.

)} {versions && versions.map((v) => ( ))}
{/* preview */}
{!sel &&
Select a version to preview
} {sel && !preview &&
} {preview && ( <>
{preview.type === "html" &&
)}
restore(confirmRestore)} onClose={() => setConfirmRestore(null)} /> ); } /* ========================================================================= * Download menu — editable docs offer .docx / PDF; binaries download as-is * ========================================================================= */ function DownloadMenu({ file, withLabel }) { const { mediaUrl } = useAuth(); const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, []); if (!file.is_text) { return ( {withLabel && Download} ); } return ( ); } /* ========================================================================= * File preview (read-only) * ========================================================================= */ function escapeHtml(s) { return (s || "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); } // Print `innerHtml` via a hidden iframe (waits for images to load first). function printHtml(title, innerHtml) { const iframe = document.createElement("iframe"); iframe.setAttribute("style", "position:fixed;right:0;bottom:0;width:0;height:0;border:0;"); document.body.appendChild(iframe); const doc = iframe.contentWindow.document; doc.open(); doc.write( '' + escapeHtml(title) + '" + innerHtml + "" ); doc.close(); const win = iframe.contentWindow; const imgs = [...doc.images]; Promise.all(imgs.map((img) => (img.complete ? Promise.resolve() : new Promise((r) => { img.onload = img.onerror = r; })))) .then(() => { win.focus(); win.print(); setTimeout(() => iframe.remove(), 1500); }); } function filenameFromResponse(res, fallback) { const cd = res.headers.get("Content-Disposition") || ""; const m = /filename="?([^"]+)"?/.exec(cd); return m ? m[1] : fallback; } function FilePreview({ file, onClose, onEdit }) { const { mediaUrl, onlyoffice } = useAuth(); const toast = useToast(); const ct = file.content_type || ""; const url = mediaUrl(file.id); const [sharing, setSharing] = useState(false); const ooView = file.is_text && onlyoffice && onlyoffice.enabled; function print() { if (file.is_text && ct === "text/html") { printHtml(file.name, file.content || ""); } else if (file.is_text) { printHtml(file.name, "
" + escapeHtml(file.content || "") + "
"); } else if (ct.startsWith("image/")) { printHtml(file.name, ''); } else { // PDFs and other binaries: open in a tab and use the browser's print window.open(url, "_blank"); } } async function share() { setSharing(true); try { const res = await fetch(mediaUrl(file.id, true)); const blob = await res.blob(); const filename = filenameFromResponse(res, file.name); const shareFile = new File([blob], filename, { type: blob.type || ct || "application/octet-stream" }); if (navigator.canShare && navigator.canShare({ files: [shareFile] })) { await navigator.share({ files: [shareFile], title: file.name }); } else { // No native file sharing here — hand back a download instead. const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); toast("Sharing files isn’t available in this browser — downloaded instead", "info"); } } catch (e) { if (e && e.name === "AbortError") return; // user dismissed the share sheet toast("Couldn’t share this file", "error"); } finally { setSharing(false); } } let body; if (ooView) { body = ; } else if (ct.startsWith("image/")) { body = {file.name}; } else if (ct === "application/pdf") { body =