89 lines
3.2 KiB
TypeScript
89 lines
3.2 KiB
TypeScript
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>
|
|
);
|
|
}
|