Three features and a typographic pass over everything. REVIEW. Picking a file and trusting it are different questions, so they now use different numbers. The existing rank decides which candidate to fetch and is right to weigh peer upload slots and queue length -- but none of that says anything about whether the file is the correct recording, and a fast peer must not be able to raise the confidence of a wrong match. So confidence() scores the filename alone: title 0.75, artist 0.25, album +0.05, and a variant tag you did not ask for multiplies by 0.8 AND caps below the auto threshold. Confidence gates the IMPORT, not the download. The transfer completes either way and the file waits in downloads, so approving is instant and rejecting is a delete. Holding the transfer instead would mean the peer is usually gone by the time anyone looks. The two cases it exists for: "02 Creep.flac" in an unsorted folder scores 0.75 -- title exactly right, artist simply absent, the commonest honest case there is -- and must be reviewed rather than binned. And "Creep (Live at Glastonbury)" scores 0.80 with BOTH artist and title perfect; the cap is what stops a live recording auto-filing over the studio version. Ask for "Creep (Acoustic)" and it auto-imports at 0.97, because then the variant is the request. DISCOVER. Related artists seeded from what you have actually imported, plus a recap of top artists, genres and decades. Built on muse's own request history, never on listening data: Navidrome owns play counts and history, and a second source of truth for those would disagree with the one you listen through. EMAIL. Optional, off unless MUSE_SMTP_HOST is set. Sends on import and on review-needed. smtplib is blocking so it runs off the event loop, and every failure is logged and swallowed -- a notification is never worth failing an import over. Google accounts need no configuration, since the identity is an address. Each user owns their own setting under Settings, deliberately not an admin route: an address you cannot change yourself is someone else's chore. DESIGN. A real type scale (display/title/headline/body/callout/caption) whose tracking tightens as size grows, which is the single biggest tell of typography nobody set. Specular top edges on glass surfaces -- blur alone reads as a grey rectangle. A two-part elevation scale, contact shadow plus soft light. Semantic colours are now per-theme: the amber tuned for the dark background failed contrast outright on white, which was exactly the text a reviewer most needs to read. The tab underline became a sliding pill so the eye tracks one object, and Review only appears when something is actually waiting. Add tests/test_matching.py: 13 assertions on the confidence bands rather than the exact scores. 90 tests pass across the three suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
355 lines
11 KiB
TypeScript
355 lines
11 KiB
TypeScript
"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-callout font-bold uppercase tracking-[0.9px] text-dim">
|
|
Users
|
|
</h3>
|
|
<p className="mt-1 text-callout 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-body 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-caption font-normal text-dim">(you)</span>}
|
|
</div>
|
|
<div className="text-caption 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-caption 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-caption">
|
|
<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-caption 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-callout " +
|
|
"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-caption 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-caption 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-caption 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-caption 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-caption 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-caption text-dim">0 means unlimited.</span>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|