Central Verification Dashboard
This commit is contained in:
88
dashboard/src/MembersPicker.tsx
Normal file
88
dashboard/src/MembersPicker.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, type Member, type UserRow } from "./api";
|
||||
|
||||
/**
|
||||
* Dropdown checklist to pick which ml_support users belong to a project. Searchable
|
||||
* + scrollable (built for ~30 users). Ticking a user adds them as a project member;
|
||||
* un-ticking removes them. Members are who can see the project and be assigned videos.
|
||||
* Admin only. By default no one is selected.
|
||||
*/
|
||||
export function MembersPicker({
|
||||
projectId,
|
||||
users,
|
||||
members,
|
||||
onChange,
|
||||
}: {
|
||||
projectId: number;
|
||||
users: UserRow[]; // all ml_support users
|
||||
members: Member[];
|
||||
onChange: () => void; // reload the project's members after a change
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const memberSet = new Set(members.map((m) => m.username));
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener("mousedown", h);
|
||||
return () => document.removeEventListener("mousedown", h);
|
||||
}, [open]);
|
||||
|
||||
const toggle = async (username: string) => {
|
||||
setBusy(username);
|
||||
setErr(null);
|
||||
try {
|
||||
if (memberSet.has(username)) await api.removeMember(projectId, username);
|
||||
else await api.addMember(projectId, username);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = users.filter((u) => u.username.toLowerCase().includes(q.trim().toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="members-picker" ref={ref}>
|
||||
<button className="btn" onClick={() => setOpen((v) => !v)}>
|
||||
{members.length === 0 ? "+ Assign users" : `Members (${members.length}) ▾`}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="members-dropdown">
|
||||
<input
|
||||
className="members-search"
|
||||
placeholder="search ml_support…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="members-list">
|
||||
{filtered.map((u) => (
|
||||
<label key={u.id} className={`members-item${memberSet.has(u.username) ? " on" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={memberSet.has(u.username)}
|
||||
disabled={busy === u.username}
|
||||
onChange={() => toggle(u.username)}
|
||||
/>
|
||||
<span className="mi-name">{u.username}</span>
|
||||
{u.display_name && u.display_name !== u.username && <span className="dim mi-disp">{u.display_name}</span>}
|
||||
</label>
|
||||
))}
|
||||
{users.length === 0 && <div className="dim mi-empty">no ml_support users yet — create some in Admin</div>}
|
||||
{users.length > 0 && filtered.length === 0 && <div className="dim mi-empty">no matches</div>}
|
||||
</div>
|
||||
{err && <div className="mi-err">⚠ {err}</div>}
|
||||
<div className="members-foot dim">{members.length} selected · ticked users can be assigned videos</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user