Add multi-user: Google OAuth, roles, quotas, admin panel
Phase 2. The schema landed last commit; this makes it real. AUTH is now multi-provider. MUSE_AUTH takes a comma list, so github,google runs both. The callback URL is unchanged — /auth/callback for every provider, with the provider carried in the state COOKIE rather than the query string — so adding Google needs no edit to an already-registered GitHub app, and the callback can't be steered to a different provider by the redirect. With more than one enabled, /auth/login shows a chooser built from style attributes only, which the CSP already permits, so it needs no stylesheet and no extra hash. Google identities are the VERIFIED email. An unverified one is refused: anyone can put someone else's address on an account they control, and the allowlist is keyed on exactly that string. WHO MAY LOG IN is now two rules — the MUSE_ALLOWED_USERS bootstrap list, then a non-disabled users row. Inviting is adding a row; no email, no token, no expiry to manage. MUSE_ALLOWED_USERS stays mandatory only while the table is empty, so a working multi-user install isn't held hostage by an env var it has outgrown, but an empty database with no allowlist still refuses to start because nobody could ever get in. The rule is one function used at login AND on every request: checking only at sign-in isn't revocation, it's a delay. QUOTAS are requests per calendar month and bytes of imported audio, 0 = unlimited. Calendar month, not a rolling window — "12 left until the 1st" is something a person can plan around. Batches are checked against the whole batch, so a 30-track album with 5 requests left is refused up front rather than accepted and abandoned halfway. Storage is measured from the file actually written, not the size the peer advertised. Admins are exempt. ADMIN PANEL: host CPU/memory/disk read straight from /proc — no psutil, no compiled dependency, and in a container /proc reports the host, which is what you want for a homelab box. Plus library totals, slskd connection state, a 30-day chart, per-user and per-artist breakdowns, and inline role/quota editing. The chart is hand-rolled SVG: a charting library would add ~150KB for one stacked bar chart and most of them inject <style> at runtime, which this app's CSP blocks. Guard rails: the last active admin cannot be demoted, disabled or deleted; disabling revokes sessions immediately; deleting a user keeps their jobs, because the history records what is in the library and why. Requests are private by default — users see their own, admins see all — and retrying someone else's job returns 404 rather than 403, since a distinct error would confirm the id exists. Also drops AnimatePresence mode="wait" from the tab container. It gates the incoming panel on the outgoing one's exit animation reporting finished, so anything that stalls the frame loop leaves the UI stuck on the old tab with no way forward — found while testing, where a backgrounded page froze navigation entirely. A cosmetic transition should never gate navigation. Verified: admin endpoints 200/403/403/401 for admin/user/guest/anon; last-admin demote, disable and delete all 409; disable revokes mid-session; quota refuses the 3rd of 2 and a 30-track batch, then succeeds once raised; users see only their own jobs; cross-user retry 404s; bad role/provider/duplicate/empty patch all rejected; multi-provider chooser and both redirects correct; three CSRF variants 400; preflight refuses unknown providers, missing secrets, and an empty database with no allowlist. CPU, memory and disk confirmed populated inside the linux/amd64 container. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3b4b68daaf
commit
f3c64d4fd0
8 files changed
+1642
-171
No files matched your search
+87
-71
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Disc3, Music2 } from "lucide-react";
|
||||
|
||||
import { api, type Me, type Track } from "@/lib/api";
|
||||
@@ -18,8 +18,9 @@ import { Requests, type JobsSnapshot } from "@/components/requests";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { PlayerProvider } from "@/components/player";
|
||||
import { ToastProvider } from "@/components/toast";
|
||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||
|
||||
type Tab = "search" | "jobs" | "bulk";
|
||||
type Tab = "search" | "jobs" | "bulk" | "admin";
|
||||
type View =
|
||||
| { k: "welcome" }
|
||||
| { k: "results"; q: string }
|
||||
@@ -97,10 +98,17 @@ function Muse() {
|
||||
} catch {
|
||||
/* a 401 here just means the poll outlived the session */
|
||||
}
|
||||
// Refetched alongside jobs rather than once at mount so the quota chip
|
||||
// counts down as you queue, and so a role change or a disable lands within
|
||||
// one poll instead of at next sign-in.
|
||||
try {
|
||||
setMe(await api.me());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
api.me().then(setMe).catch(() => undefined);
|
||||
void refresh();
|
||||
const t = setInterval(refresh, 4000);
|
||||
return () => clearInterval(t);
|
||||
@@ -111,6 +119,12 @@ function Muse() {
|
||||
(snap?.counts.searching ?? 0) +
|
||||
(snap?.counts.downloading ?? 0);
|
||||
|
||||
const isAdmin = me?.role === "admin";
|
||||
// Only worth showing when a limit actually exists — a permanent "0 / 0" chip
|
||||
// is noise on a single-user install, which is most of them.
|
||||
const quotaLeft =
|
||||
me?.quota && me.usage ? Math.max(0, me.quota - me.usage.requests) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Aurora />
|
||||
@@ -135,6 +149,21 @@ function Muse() {
|
||||
</div>
|
||||
{me?.user && (
|
||||
<div className="flex items-center gap-2 text-[12.5px] text-dim">
|
||||
{quotaLeft !== null && (
|
||||
<span
|
||||
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
|
||||
className={cn(
|
||||
"hidden rounded-full border px-2 py-0.5 text-[11px] sm:inline",
|
||||
quotaLeft === 0
|
||||
? "border-err/50 text-err"
|
||||
: quotaLeft <= 5
|
||||
? "border-warn/50 text-warn"
|
||||
: "border-edge text-dim",
|
||||
)}
|
||||
>
|
||||
{quotaLeft} left
|
||||
</span>
|
||||
)}
|
||||
{me.avatar && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
@@ -166,12 +195,13 @@ function Muse() {
|
||||
|
||||
<SearchBar busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
||||
|
||||
<nav className="flex pt-3">
|
||||
<nav className="flex overflow-x-auto pt-3 no-scrollbar">
|
||||
{(
|
||||
[
|
||||
["search", "Search"],
|
||||
["jobs", "Requests"],
|
||||
["bulk", "Import list"],
|
||||
...(isAdmin ? ([["admin", "Admin"]] as [Tab, string][]) : []),
|
||||
] as [Tab, string][]
|
||||
).map(([k, label]) => (
|
||||
<button
|
||||
@@ -206,16 +236,22 @@ function Muse() {
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-[940px] px-4 pb-32 pt-5">
|
||||
<AnimatePresence mode="wait">
|
||||
{tab === "search" && (
|
||||
<motion.div
|
||||
key="search"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
{history.length > 0 && (
|
||||
{/* Keyed on `tab`, so React remounts and Framer replays the entrance.
|
||||
Deliberately NOT wrapped in AnimatePresence mode="wait": that holds
|
||||
the incoming panel until the outgoing one's exit animation reports
|
||||
finished, which makes navigation depend on an animation completing.
|
||||
Anything that stalls the frame loop — a backgrounded tab, a browser
|
||||
that throttles rAF — then leaves the UI stuck on the old panel with
|
||||
no way forward. A cosmetic transition should never gate navigation. */}
|
||||
<motion.div
|
||||
key={tab}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
{tab === "search" && (
|
||||
<>
|
||||
{history.length > 0 && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
@@ -223,67 +259,47 @@ function Muse() {
|
||||
className="glass mb-3.5 flex cursor-pointer items-center gap-1.5 rounded-full
|
||||
px-3 py-1.5 text-[13px] text-dim transition hover:border-edge-hi hover:text-ink"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> Back
|
||||
</motion.button>
|
||||
)}
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> Back
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{view.k === "welcome" && <Welcome onPick={doSearch} />}
|
||||
{view.k === "welcome" && <Welcome onPick={doSearch} />}
|
||||
|
||||
{view.k === "results" &&
|
||||
(results === null ? (
|
||||
<Skeletons />
|
||||
) : results.length === 0 ? (
|
||||
<div className="py-12 text-center text-dim">
|
||||
No matches. Try adding the artist name.
|
||||
</div>
|
||||
) : (
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
{results.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.deezer_id}
|
||||
track={t}
|
||||
index={i}
|
||||
onAlbum={openAlbum}
|
||||
onArtist={openArtist}
|
||||
onQueued={refresh}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
))}
|
||||
{view.k === "results" &&
|
||||
(results === null ? (
|
||||
<Skeletons />
|
||||
) : results.length === 0 ? (
|
||||
<div className="py-12 text-center text-dim">
|
||||
No matches. Try adding the artist name.
|
||||
</div>
|
||||
) : (
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
{results.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.deezer_id}
|
||||
track={t}
|
||||
index={i}
|
||||
onAlbum={openAlbum}
|
||||
onArtist={openArtist}
|
||||
onQueued={refresh}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{view.k === "album" && (
|
||||
<AlbumView id={view.id} onArtist={openArtist} onQueued={refresh} />
|
||||
)}
|
||||
{view.k === "artist" && (
|
||||
<ArtistView id={view.id} onAlbum={openAlbum} onQueued={refresh} />
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
{view.k === "album" && (
|
||||
<AlbumView id={view.id} onArtist={openArtist} onQueued={refresh} />
|
||||
)}
|
||||
{view.k === "artist" && (
|
||||
<ArtistView id={view.id} onAlbum={openAlbum} onQueued={refresh} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "jobs" && (
|
||||
<motion.div
|
||||
key="jobs"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
<Requests snap={snap} refresh={refresh} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{tab === "bulk" && (
|
||||
<motion.div
|
||||
key="bulk"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
<BulkImport onQueued={refresh} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
||||
{tab === "bulk" && <BulkImport onQueued={refresh} />}
|
||||
{tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
||||
</motion.div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import type { Activity } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Hand-rolled SVG rather than a charting library.
|
||||
*
|
||||
* Recharts/Chart.js would add ~150KB to a page that needs one stacked bar
|
||||
* chart, and most of them inject <style> at runtime, which this app's CSP
|
||||
* blocks outright. An SVG with two rects per day is a few dozen lines and has
|
||||
* neither problem.
|
||||
*/
|
||||
export function ActivityChart({ data }: { data: Activity }) {
|
||||
const [hover, setHover] = React.useState<number | null>(null);
|
||||
|
||||
const max = Math.max(1, ...data.series.map((d) => d.n));
|
||||
const W = 100; // viewBox units — the SVG scales to its container
|
||||
const H = 32;
|
||||
const gap = 0.18;
|
||||
const bw = W / data.series.length;
|
||||
|
||||
const shown = hover != null ? data.series[hover] : null;
|
||||
const totals = data.series.reduce(
|
||||
(a, d) => ({
|
||||
n: a.n + d.n,
|
||||
imported: a.imported + d.imported,
|
||||
failed: a.failed + d.failed,
|
||||
}),
|
||||
{ n: 0, imported: 0, failed: 0 },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-[13px] font-bold uppercase tracking-[0.9px] text-dim">
|
||||
Requests · last {data.days} days
|
||||
</h3>
|
||||
<div className="mt-1 text-[22px] font-bold tracking-[-0.6px]">
|
||||
{shown ? shown.n : totals.n}
|
||||
<span className="ml-2 text-[13px] font-normal text-dim">
|
||||
{shown
|
||||
? new Date(shown.date + "T00:00:00").toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: "total"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 text-[11.5px] text-dim">
|
||||
<Legend className="bg-accent" label={`${shown ? shown.imported : totals.imported} imported`} />
|
||||
<Legend className="bg-err" label={`${shown ? shown.failed : totals.failed} failed`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="h-28 w-full overflow-visible"
|
||||
onMouseLeave={() => setHover(null)}
|
||||
role="img"
|
||||
aria-label={`Daily request volume over the last ${data.days} days`}
|
||||
>
|
||||
{data.series.map((d, i) => {
|
||||
const h = (d.n / max) * H;
|
||||
const impH = d.n ? (d.imported / d.n) * h : 0;
|
||||
const x = i * bw;
|
||||
const on = hover === i;
|
||||
return (
|
||||
<g key={d.date} onMouseEnter={() => setHover(i)}>
|
||||
{/* Full-height hit area — 3px bars are impossible to hover. */}
|
||||
<rect x={x} y={0} width={bw} height={H} fill="transparent" />
|
||||
<motion.rect
|
||||
initial={{ height: 0, y: H }}
|
||||
animate={{ height: Math.max(h, d.n ? 0.6 : 0), y: H - Math.max(h, d.n ? 0.6 : 0) }}
|
||||
transition={{ delay: Math.min(i * 0.008, 0.3), duration: 0.45, ease: "easeOut" }}
|
||||
x={x + bw * gap}
|
||||
width={bw * (1 - gap * 2)}
|
||||
rx={0.5}
|
||||
className="fill-err"
|
||||
opacity={on ? 1 : 0.75}
|
||||
/>
|
||||
<motion.rect
|
||||
initial={{ height: 0, y: H }}
|
||||
animate={{ height: impH, y: H - impH }}
|
||||
transition={{ delay: Math.min(i * 0.008, 0.3), duration: 0.45, ease: "easeOut" }}
|
||||
x={x + bw * gap}
|
||||
width={bw * (1 - gap * 2)}
|
||||
rx={0.5}
|
||||
className="fill-accent"
|
||||
opacity={on ? 1 : 0.85}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<div className="mt-1.5 flex justify-between text-[11px] text-dim">
|
||||
<span>{fmt(data.series[0]?.date)}</span>
|
||||
<span>{fmt(data.series[data.series.length - 1]?.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ className, label }: { className: string; label: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<i className={`inline-block h-2 w-2 rounded-sm ${className}`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const fmt = (d?: string) =>
|
||||
d
|
||||
? new Date(d + "T00:00:00").toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: "";
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||
import { api, bytes, type Activity, type AdminUser, type SystemStats } from "@/lib/api";
|
||||
import { StatCard } from "@/components/admin/stat-card";
|
||||
import { ActivityChart } from "@/components/admin/activity-chart";
|
||||
import { UsersTable } from "@/components/admin/users-table";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AdminPanel({ me }: { me: string | null }) {
|
||||
const [sys, setSys] = React.useState<SystemStats | null>(null);
|
||||
const [act, setAct] = React.useState<Activity | null>(null);
|
||||
const [users, setUsers] = React.useState<{ users: AdminUser[]; roles: string[] } | null>(null);
|
||||
const [err, setErr] = React.useState<string | null>(null);
|
||||
|
||||
const loadUsers = React.useCallback(() => {
|
||||
api.admin.users().then(setUsers).catch((e) => setErr((e as Error).message));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadUsers();
|
||||
api.admin.activity(30).then(setAct).catch(() => undefined);
|
||||
const poll = () => api.admin.system().then(setSys).catch(() => undefined);
|
||||
poll();
|
||||
// 5s: CPU is a delta between samples, so the first reading is always null
|
||||
// and a slow poll would leave it null for an uncomfortably long time.
|
||||
const t = setInterval(poll, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [loadUsers]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
|
||||
if (!sys || !users) return <Skeletons n={6} />;
|
||||
|
||||
const s = sys.system;
|
||||
const lib = s.disks.library;
|
||||
const active = (sys.jobs.queued ?? 0) + (sys.jobs.searching ?? 0) + (sys.jobs.downloading ?? 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SlskdBanner slskd={sys.slskd} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<StatCard
|
||||
index={0}
|
||||
label="CPU"
|
||||
value={s.cpu_pct == null ? "—" : `${s.cpu_pct}%`}
|
||||
sub={s.load ? `load ${s.load.join(" · ")}${s.cpus ? ` on ${s.cpus}` : ""}` : undefined}
|
||||
pct={s.cpu_pct}
|
||||
/>
|
||||
<StatCard
|
||||
index={1}
|
||||
label="Memory"
|
||||
value={s.mem_pct == null ? "—" : `${s.mem_pct}%`}
|
||||
sub={s.mem_total ? `${bytes(s.mem_used)} of ${bytes(s.mem_total)}` : undefined}
|
||||
pct={s.mem_pct}
|
||||
/>
|
||||
<StatCard
|
||||
index={2}
|
||||
label="Library disk"
|
||||
value={lib ? `${lib.pct}%` : "—"}
|
||||
sub={lib ? `${bytes(lib.free)} free` : undefined}
|
||||
pct={lib?.pct ?? null}
|
||||
/>
|
||||
<StatCard
|
||||
index={3}
|
||||
label="Library"
|
||||
value={sys.library.tracks.toLocaleString()}
|
||||
sub={`${bytes(sys.library.bytes)} imported`}
|
||||
/>
|
||||
<StatCard index={4} label="In flight" value={active} sub={`${sys.pending} waiting · ${sys.workers} workers`} />
|
||||
<StatCard index={5} label="Failed" value={sys.jobs.failed ?? 0} sub="all time" />
|
||||
<StatCard index={6} label="Users" value={sys.users} sub={`${sys.sessions} active sessions`} />
|
||||
<StatCard
|
||||
index={7}
|
||||
label="Shared files"
|
||||
value={sys.slskd.shared_files?.toLocaleString() ?? "—"}
|
||||
sub="advertised to Soulseek"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{act && <ActivityChart data={act} />}
|
||||
|
||||
{act && act.per_user.length > 0 && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Breakdown
|
||||
title="By user"
|
||||
rows={act.per_user.map((r) => ({
|
||||
label: r.u,
|
||||
n: r.n,
|
||||
sub: r.bytes ? bytes(r.bytes) : "",
|
||||
}))}
|
||||
/>
|
||||
<Breakdown
|
||||
title="Most requested artists"
|
||||
rows={act.top_artists.map((r) => ({ label: r.artist, n: r.n, sub: "" }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UsersTable users={users.users} roles={users.roles} me={me} refresh={loadUsers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlskdBanner({ slskd }: { slskd: SystemStats["slskd"] }) {
|
||||
const healthy = slskd.ok && slskd.connected && slskd.logged_in;
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"glass flex items-center gap-2.5 rounded-glass px-4 py-2.5 text-[13px]",
|
||||
healthy ? "border-ok/30" : "border-err/40",
|
||||
)}
|
||||
>
|
||||
{healthy ? (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-ok" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-err" />
|
||||
)}
|
||||
<span className={healthy ? "text-dim" : "text-err"}>
|
||||
{healthy
|
||||
? `slskd connected and logged in${slskd.version ? ` · v${slskd.version}` : ""}`
|
||||
: slskd.error
|
||||
? `slskd unreachable — ${slskd.error}`
|
||||
: `slskd reachable but not logged in to Soulseek — nothing will download`}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function Breakdown({
|
||||
title,
|
||||
rows,
|
||||
}: {
|
||||
title: string;
|
||||
rows: { label: string; n: number; sub: string }[];
|
||||
}) {
|
||||
const max = Math.max(1, ...rows.map((r) => r.n));
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<h3 className="mb-3 text-[13px] font-bold uppercase tracking-[0.9px] text-dim">{title}</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.slice(0, 8).map((r, i) => (
|
||||
<motion.div
|
||||
key={r.label}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.04 }}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<span className="w-32 shrink-0 text-[13px] clamp1">{r.label}</span>
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-faint">
|
||||
<motion.i
|
||||
className="block h-full rounded-full bg-accent"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(r.n / max) * 100}%` }}
|
||||
transition={{ delay: i * 0.04, duration: 0.5, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-10 shrink-0 text-right text-[12.5px] tabular-nums text-dim">
|
||||
{r.n}
|
||||
</span>
|
||||
{r.sub && (
|
||||
<span className="hidden w-16 shrink-0 text-right text-[11.5px] text-dim sm:block">
|
||||
{r.sub}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** A metric with an optional usage bar. Bar colour escalates with pressure —
|
||||
* a disk at 94% should not look the same as one at 12%. */
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
pct,
|
||||
index = 0,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
sub?: React.ReactNode;
|
||||
pct?: number | null;
|
||||
index?: number;
|
||||
}) {
|
||||
const tone =
|
||||
pct == null ? "bg-accent" : pct >= 90 ? "bg-err" : pct >= 75 ? "bg-warn" : "bg-accent";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.04, 0.4), type: "spring", stiffness: 320, damping: 30 }}
|
||||
className="glass rounded-glass p-3.5"
|
||||
>
|
||||
<div className="text-[11.5px] uppercase tracking-[0.6px] text-dim">{label}</div>
|
||||
<div className="mt-0.5 text-[22px] font-bold leading-tight tracking-[-0.6px]">
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="mt-0.5 text-[11.5px] text-dim clamp1">{sub}</div>}
|
||||
{pct != null && (
|
||||
<div className="mt-2 h-1 overflow-hidden rounded-full bg-faint">
|
||||
<motion.i
|
||||
className={cn("block h-full rounded-full", tone)}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Ban, Check, Plus, ShieldCheck, Trash2, Undo2, X } from "lucide-react";
|
||||
import { api, bytes, type AdminUser } from "@/lib/api";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function UsersTable({
|
||||
users,
|
||||
roles,
|
||||
me,
|
||||
refresh,
|
||||
}: {
|
||||
users: AdminUser[];
|
||||
roles: string[];
|
||||
me: string | null;
|
||||
refresh: () => void;
|
||||
}) {
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-[13px] font-bold uppercase tracking-[0.9px] text-dim">
|
||||
Users
|
||||
</h3>
|
||||
<p className="mt-1 text-[12.5px] text-dim">
|
||||
Adding someone here <em>is</em> the invitation — no email, no token. They sign in
|
||||
with that provider and they’re in.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="pill" variant={adding ? "ghost" : "primary"} onClick={() => setAdding(!adding)}>
|
||||
{adding ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{adding ? "Cancel" : "Invite"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{adding && (
|
||||
<InviteForm
|
||||
roles={roles}
|
||||
onDone={() => {
|
||||
setAdding(false);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<AnimatePresence initial={false}>
|
||||
{users.map((u) => (
|
||||
<UserRow key={u.username} u={u} roles={roles} isMe={u.username === me} refresh={refresh} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
u,
|
||||
roles,
|
||||
isMe,
|
||||
refresh,
|
||||
}: {
|
||||
u: AdminUser;
|
||||
roles: string[];
|
||||
isMe: boolean;
|
||||
refresh: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [confirmDel, setConfirmDel] = React.useState(false);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, ok: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
toast(ok);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const quotaPct = u.quota ? (u.usage.requests / u.quota) * 100 : null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 360, damping: 32 }}
|
||||
className={cn(
|
||||
"glass-hi rounded-glass p-3",
|
||||
u.disabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="h-9 w-9 shrink-0 overflow-hidden rounded-full bg-faint">
|
||||
{u.avatar && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={u.avatar}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-[14px] font-semibold clamp1">
|
||||
{u.display || u.username}
|
||||
{u.role === "admin" && <ShieldCheck className="h-3.5 w-3.5 shrink-0 text-accent" />}
|
||||
{isMe && <span className="text-[11px] font-normal text-dim">(you)</span>}
|
||||
</div>
|
||||
<div className="text-[12px] text-dim clamp1">
|
||||
{u.username} · {u.provider}
|
||||
{u.last_seen && ` · seen ${u.last_seen.slice(0, 10)}`}
|
||||
{u.disabled && " · disabled"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={u.role}
|
||||
disabled={busy}
|
||||
onChange={(e) => run(() => api.admin.patchUser(u.username, { role: e.target.value }), `${u.username} is now ${e.target.value}`)}
|
||||
aria-label={`Role for ${u.username}`}
|
||||
className="shrink-0 cursor-pointer rounded-full border border-edge-hi bg-transparent
|
||||
px-2.5 py-1 text-[12px] text-ink outline-none
|
||||
focus:border-accent-line"
|
||||
>
|
||||
{roles.map((r) => (
|
||||
<option key={r} value={r} className="bg-[#12141c]">
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="quiet"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => api.admin.patchUser(u.username, { disabled: !u.disabled }),
|
||||
u.disabled ? `${u.username} re-enabled` : `${u.username} disabled`,
|
||||
)
|
||||
}
|
||||
title={u.disabled ? "Re-enable" : "Disable and revoke sessions"}
|
||||
>
|
||||
{u.disabled ? <Undo2 className="h-3.5 w-3.5" /> : <Ban className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
|
||||
{confirmDel ? (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="quiet"
|
||||
disabled={busy}
|
||||
className="border-err/50 text-err"
|
||||
onClick={() => run(() => api.admin.deleteUser(u.username), `${u.username} deleted`)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" /> Delete
|
||||
</Button>
|
||||
<Button size="sm" variant="quiet" onClick={() => setConfirmDel(false)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="quiet"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmDel(true)}
|
||||
title="Delete user (their request history is kept)"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-2 pl-12 text-[12px]">
|
||||
<QuotaField
|
||||
label="Requests / month"
|
||||
value={u.quota}
|
||||
suffix={`used ${u.usage.requests}`}
|
||||
pct={quotaPct}
|
||||
onSave={(v) => run(() => api.admin.patchUser(u.username, { quota: v }), "Quota updated")}
|
||||
/>
|
||||
<QuotaField
|
||||
label="Storage (GB)"
|
||||
value={+(u.storage_quota / 2 ** 30).toFixed(1)}
|
||||
suffix={`used ${bytes(u.usage.storage)}`}
|
||||
pct={u.storage_quota ? (u.usage.storage / u.storage_quota) * 100 : null}
|
||||
onSave={(v) =>
|
||||
run(() => api.admin.patchUser(u.username, { storage_quota_gb: v }), "Quota updated")
|
||||
}
|
||||
/>
|
||||
{u.bootstrap && (
|
||||
<span className="text-[11.5px] text-warn" title="Listed in MUSE_ALLOWED_USERS">
|
||||
bootstrap account — disabling won’t lock them out
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Number input that only commits on blur or Enter, so a quota isn't PATCHed
|
||||
* once per keystroke while you type "120". */
|
||||
function QuotaField({
|
||||
label,
|
||||
value,
|
||||
suffix,
|
||||
pct,
|
||||
onSave,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
suffix: string;
|
||||
pct: number | null;
|
||||
onSave: (v: number) => void;
|
||||
}) {
|
||||
const [v, setV] = React.useState(String(value));
|
||||
React.useEffect(() => setV(String(value)), [value]);
|
||||
|
||||
const commit = () => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
setV(String(value));
|
||||
return;
|
||||
}
|
||||
if (n !== value) onSave(n);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-dim">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={v}
|
||||
onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||
className="w-16 rounded-md border border-edge-hi bg-transparent px-1.5 py-0.5
|
||||
text-right text-ink outline-none focus:border-accent-line"
|
||||
/>
|
||||
<span className={cn("text-dim", pct != null && pct >= 90 && "text-err")}>
|
||||
{Number(v) === 0 ? "unlimited" : suffix}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void }) {
|
||||
const toast = useToast();
|
||||
const [username, setUsername] = React.useState("");
|
||||
const [provider, setProvider] = React.useState("github");
|
||||
const [role, setRole] = React.useState("user");
|
||||
const [quota, setQuota] = React.useState("0");
|
||||
const [storage, setStorage] = React.useState("0");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!username.trim()) {
|
||||
toast("Username required", true);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.admin.addUser({
|
||||
username: username.trim(),
|
||||
provider,
|
||||
role,
|
||||
quota: Number(quota) || 0,
|
||||
storage_quota_gb: Number(storage) || 0,
|
||||
});
|
||||
toast(`Invited ${username.trim()}`);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const input =
|
||||
"rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-[13px] " +
|
||||
"text-ink outline-none focus:border-accent-line";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="glass-hi mt-1 flex flex-wrap items-end gap-2.5 rounded-glass p-3">
|
||||
<label className="flex min-w-[210px] flex-1 flex-col gap-1">
|
||||
<span className="text-[11.5px] text-dim">
|
||||
{provider === "github" ? "GitHub username" : "Google email"}
|
||||
</span>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
placeholder={provider === "github" ? "octocat" : "friend@gmail.com"}
|
||||
className={input}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11.5px] text-dim">Provider</span>
|
||||
<select value={provider} onChange={(e) => setProvider(e.target.value)} className={cn(input, "cursor-pointer")}>
|
||||
<option value="github" className="bg-[#12141c]">GitHub</option>
|
||||
<option value="google" className="bg-[#12141c]">Google</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11.5px] text-dim">Role</span>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)} className={cn(input, "cursor-pointer")}>
|
||||
{roles.map((r) => (
|
||||
<option key={r} value={r} className="bg-[#12141c]">
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex w-24 flex-col gap-1">
|
||||
<span className="text-[11.5px] text-dim">Req/month</span>
|
||||
<input type="number" min={0} value={quota} onChange={(e) => setQuota(e.target.value)} className={input} />
|
||||
</label>
|
||||
<label className="flex w-24 flex-col gap-1">
|
||||
<span className="text-[11.5px] text-dim">Storage GB</span>
|
||||
<input type="number" min={0} value={storage} onChange={(e) => setStorage(e.target.value)} className={input} />
|
||||
</label>
|
||||
<Button onClick={submit} disabled={busy}>
|
||||
{busy ? "Inviting…" : "Invite"}
|
||||
</Button>
|
||||
<span className="w-full text-[11.5px] text-dim">0 means unlimited.</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
+114
-4
@@ -92,13 +92,71 @@ export type Job = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Usage = { requests: number; storage: number };
|
||||
|
||||
export type Me = {
|
||||
user: string | null;
|
||||
auth: string;
|
||||
role?: string;
|
||||
role?: string | null;
|
||||
display?: string | null;
|
||||
avatar?: string | null;
|
||||
provider?: string;
|
||||
quota?: number;
|
||||
storage_quota?: number;
|
||||
usage?: Usage;
|
||||
providers?: string[];
|
||||
};
|
||||
|
||||
export type AdminUser = {
|
||||
username: string;
|
||||
provider: string;
|
||||
role: string;
|
||||
display: string | null;
|
||||
avatar: string | null;
|
||||
quota: number;
|
||||
storage_quota: number;
|
||||
disabled: boolean;
|
||||
created_at: string;
|
||||
last_seen: string | null;
|
||||
invited_by: string | null;
|
||||
usage: Usage;
|
||||
/** In MUSE_ALLOWED_USERS — cannot actually be locked out by disabling. */
|
||||
bootstrap: boolean;
|
||||
};
|
||||
|
||||
export type DiskStat = { total: number; used: number; free: number; pct: number | null };
|
||||
|
||||
export type SystemStats = {
|
||||
system: {
|
||||
cpu_pct: number | null;
|
||||
cpus: number | null;
|
||||
mem_total: number | null;
|
||||
mem_used: number | null;
|
||||
mem_pct: number | null;
|
||||
load: number[] | null;
|
||||
disks: Record<string, DiskStat | null>;
|
||||
};
|
||||
workers: number;
|
||||
pending: number;
|
||||
jobs: Record<string, number>;
|
||||
library: { tracks: number; bytes: number };
|
||||
users: number;
|
||||
sessions: number;
|
||||
slskd: {
|
||||
ok: boolean;
|
||||
connected?: boolean;
|
||||
logged_in?: boolean;
|
||||
version?: string;
|
||||
shared_files?: number | null;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Activity = {
|
||||
days: number;
|
||||
series: { date: string; n: number; imported: number; failed: number }[];
|
||||
per_user: { u: string; n: number; bytes: number }[];
|
||||
top_artists: { artist: string; n: number }[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -118,18 +176,33 @@ export class ApiError extends Error {
|
||||
async function get<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const r = await fetch(path, { credentials: "same-origin", ...init });
|
||||
if (r.status === 401) throw new ApiError(401, "signed out");
|
||||
if (!r.ok) throw new ApiError(r.status, `HTTP ${r.status}`);
|
||||
if (!r.ok) {
|
||||
// FastAPI puts the useful part in `detail` — quota messages, "only active
|
||||
// admin", and so on. Surfacing "HTTP 429" instead would throw away the
|
||||
// only text that tells the user what to do about it.
|
||||
let msg = `HTTP ${r.status}`;
|
||||
try {
|
||||
const body = await r.json();
|
||||
if (typeof body?.detail === "string") msg = body.detail;
|
||||
else if (typeof body?.error === "string") msg = body.error;
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
throw new ApiError(r.status, msg);
|
||||
}
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
function send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
return get<T>(path, {
|
||||
method: "POST",
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const post = <T,>(path: string, body?: unknown) => send<T>("POST", path, body);
|
||||
|
||||
export const api = {
|
||||
me: () => get<Me>("/api/me"),
|
||||
search: (q: string) =>
|
||||
@@ -161,8 +234,45 @@ export const api = {
|
||||
skipped: { line: string; why: string }[];
|
||||
pending: number;
|
||||
}>("/api/import", { text }),
|
||||
|
||||
admin: {
|
||||
users: () => get<{ users: AdminUser[]; roles: string[] }>("/api/admin/users"),
|
||||
addUser: (u: {
|
||||
username: string;
|
||||
provider: string;
|
||||
role: string;
|
||||
quota: number;
|
||||
storage_quota_gb: number;
|
||||
}) => post<AdminUser>("/api/admin/users", u),
|
||||
patchUser: (
|
||||
username: string,
|
||||
patch: Partial<{
|
||||
role: string;
|
||||
quota: number;
|
||||
storage_quota_gb: number;
|
||||
disabled: boolean;
|
||||
}>,
|
||||
) => send<AdminUser>("PATCH", `/api/admin/users/${encodeURIComponent(username)}`, patch),
|
||||
deleteUser: (username: string) =>
|
||||
send<{ ok: boolean }>("DELETE", `/api/admin/users/${encodeURIComponent(username)}`),
|
||||
system: () => get<SystemStats>("/api/admin/system"),
|
||||
activity: (days = 30) => get<Activity>(`/api/admin/activity?days=${days}`),
|
||||
},
|
||||
};
|
||||
|
||||
/** Bytes → human. Binary units, because that's what df and the OS report. */
|
||||
export function bytes(n: number | null | undefined): string {
|
||||
if (!n) return "0 B";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||
let i = 0;
|
||||
let v = n;
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`;
|
||||
}
|
||||
|
||||
export const mmss = (ms: number | null | undefined) => {
|
||||
const t = Math.round((ms || 0) / 1000);
|
||||
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`;
|
||||
|
||||
Reference in new issue
Block a user