diff --git a/app/(dashboard)/users/disciple-link-modal.tsx b/app/(dashboard)/users/disciple-link-modal.tsx new file mode 100644 index 0000000..c443f21 --- /dev/null +++ b/app/(dashboard)/users/disciple-link-modal.tsx @@ -0,0 +1,246 @@ +"use client"; + +/** + * DiscipleLinkModal — links a platform account to one of Śrīla Prabhupāda's + * initiated disciples (or clears an existing link). + * + * The disciple directory lives in Mongo and the link column on the Postgres + * users row, so the picker searches server-side via /admin/disciples. Each + * option reports whether another account already claims it, letting us disable + * those rows up front instead of surfacing a 409 after the click. + * + * Clicking a free row links immediately — the action is one field, instantly + * reversible from the same modal, so a confirm step would only add friction. + */ + +import { useCallback, useEffect, useState } from "react"; +import { Check, Link2Off, Search } from "lucide-react"; +import { toast } from "sonner"; +import { api, AdminError, type AdminDisciple, type AdminUser } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Modal } from "@/components/ui/modal"; +import { cn } from "@/lib/utils"; + +/** "Vṛndāvana · 1972" — whichever halves we know. */ +function placeYear(d: AdminDisciple): string | null { + const year = d.initiation_date + ? new Date(d.initiation_date).getFullYear() + : null; + const parts = [d.initiation_place, Number.isFinite(year) ? year : null].filter( + Boolean, + ); + return parts.length ? parts.join(" · ") : null; +} + +export function DiscipleLinkModal({ + user, + onClose, + onSaved, +}: { + user: AdminUser | null; + onClose: () => void; + onSaved: () => void; +}) { + const [q, setQ] = useState(""); + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + const [busyId, setBusyId] = useState(null); + + // Reset the query each time the modal opens for a different user, so a + // stale search doesn't carry over between rows. + useEffect(() => { + if (user) setQ(""); + }, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + // Debounced search. Runs once on open (empty q → first page alphabetically) + // and then 250ms after the operator stops typing. + useEffect(() => { + if (!user) return; + let cancelled = false; + setLoading(true); + const t = setTimeout(async () => { + try { + const items = await api.disciples(q); + if (!cancelled) setOptions(items); + } catch (err) { + if (!cancelled) { + toast.error( + err instanceof AdminError ? err.message : "Could not load disciples.", + ); + setOptions([]); + } + } finally { + if (!cancelled) setLoading(false); + } + }, 250); + return () => { + cancelled = true; + clearTimeout(t); + }; + }, [q, user?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + const link = useCallback( + async (d: AdminDisciple) => { + if (!user) return; + setBusyId(d.id); + try { + await api.linkDisciple(user.id, d.id); + toast.success( + `@${user.handle} linked to ${d.initiated_name} ${d.suffix}`.trim(), + ); + onSaved(); + onClose(); + } catch (err) { + toast.error( + err instanceof AdminError ? err.message : "Could not link disciple.", + ); + } finally { + setBusyId(null); + } + }, + [user, onSaved, onClose], + ); + + async function unlink() { + if (!user) return; + setBusyId("__unlink__"); + try { + await api.unlinkDisciple(user.id); + toast.success(`Removed the disciple link from @${user.handle}`); + onSaved(); + onClose(); + } catch (err) { + toast.error( + err instanceof AdminError ? err.message : "Could not remove the link.", + ); + } finally { + setBusyId(null); + } + } + + return ( + +
+

+ Link this account to one of Śrīla Prabhupāda’s initiated + disciples. Linked accounts show a badge on their profile and are + highlighted in the disciples directory. +

+ + {user?.disciple_id ? ( +
+
+
+ Currently linked to +
+
+ {user.disciple_name ?? ( + + unknown disciple ({user.disciple_id.slice(0, 8)}…) + + )} +
+
+ +
+ ) : null} + +
+ + setQ(e.target.value)} + placeholder="Search initiated names…" + className="pl-9" + autoFocus + /> +
+ +
+ {loading && options.length === 0 ? ( +

+ Searching… +

+ ) : options.length === 0 ? ( +

+ No disciples match that search. +

+ ) : ( +
    + {options.map((d) => { + const isCurrent = d.id === user?.disciple_id; + // Claimed by somebody else — not linkable until that link goes. + const takenByOther = !!d.linked_handle && !isCurrent; + const sub = placeYear(d); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ +
+ +
+
+
+ ); +} diff --git a/app/(dashboard)/users/page.tsx b/app/(dashboard)/users/page.tsx index 3c7259b..5bdd32c 100644 --- a/app/(dashboard)/users/page.tsx +++ b/app/(dashboard)/users/page.tsx @@ -20,6 +20,7 @@ import { Avatar } from "@/components/ui/avatar"; import { useConfirm } from "@/components/confirm"; import { Input, Textarea } from "@/components/ui/input"; import { fmtDate } from "@/lib/utils"; +import { DiscipleLinkModal } from "./disciple-link-modal"; export default function UsersPage() { const [q, setQ] = useState(""); @@ -31,6 +32,7 @@ export default function UsersPage() { const confirm = useConfirm(); const [notify, setNotify] = useState(null); const [limitsUser, setLimitsUser] = useState(null); + const [discipleUser, setDiscipleUser] = useState(null); async function toggleBan(u: AdminUser) { const banning = !u.banned_at; @@ -54,11 +56,42 @@ export default function UsersPage() { } } + async function removeDiscipleLink(u: AdminUser) { + if ( + !(await confirm({ + title: `Remove disciple link from @${u.handle}?`, + message: + "Their profile badge and the highlight in the disciples directory will be removed. You can re-link them at any time.", + confirmLabel: "Remove link", + destructive: true, + })) + ) { + return; + } + try { + await api.unlinkDisciple(u.id); + toast.success("Disciple link removed."); + list.refresh(); + } catch (err) { + toast.error(err instanceof AdminError ? err.message : "Action failed."); + } + } + function menuItems(u: AdminUser): MenuItem[] { const items: MenuItem[] = [ { label: "Send notification", onClick: () => setNotify(u) }, { label: "Edit limits", onClick: () => setLimitsUser(u) }, + { + label: u.disciple_id ? "Change disciple link" : "Link direct disciple", + onClick: () => setDiscipleUser(u), + }, ]; + if (u.disciple_id) { + items.push({ + label: "Remove disciple link", + onClick: () => removeDiscipleLink(u), + }); + } // No ban option for fellow admins (avoid locking each other out). if (u.role !== "admin") { items.push({ @@ -109,6 +142,20 @@ export default function UsersPage() {
@{u.handle}
+ {u.disciple_id ? ( +
+ + {u.disciple_name ?? "Direct disciple"} + +
+ ) : null} @@ -139,6 +186,11 @@ export default function UsersPage() { onClose={() => setLimitsUser(null)} onSaved={list.refresh} /> + setDiscipleUser(null)} + onSaved={list.refresh} + /> ); } diff --git a/lib/api.ts b/lib/api.ts index 97b8e27..cb6eec0 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -133,6 +133,22 @@ export type AdminUser = { bookmark_limit: number; public_collection_limit: number; private_collection_limit: number; + /** Direct-disciple link: the Mongo disciple id this account is linked to, + * and that disciple's name (hydrated server-side). Both null when the + * account isn't one of Śrīla Prabhupāda's direct disciples. */ + disciple_id: string | null; + disciple_name: string | null; +}; + +/** One option in the direct-disciple picker. `linked_handle` is the @handle of + * the account already linked to this disciple — non-null means "taken". */ +export type AdminDisciple = { + id: string; + initiated_name: string; + suffix: string; + initiation_date: string | null; + initiation_place: string | null; + linked_handle: string | null; }; /** Per-user save-limit override payload. null = reset that limit to the plan @@ -287,6 +303,24 @@ export const api = { body: JSON.stringify(limits), }); }, + /** Search Śrīla Prabhupāda's disciples for the link picker. An empty query + * returns the first page alphabetically. */ + async disciples(q: string, limit = 25): Promise { + const { data } = await call( + `/admin/disciples${qs({ q, limit })}`, + ); + return data ?? []; + }, + /** Mark a user as a direct disciple. Re-linking moves an existing link. */ + linkDisciple(userId: string, discipleId: string) { + return call(`/admin/users/${userId}/disciple`, { + method: "POST", + body: JSON.stringify({ disciple_id: discipleId }), + }); + }, + unlinkDisciple(userId: string) { + return call(`/admin/users/${userId}/disciple`, { method: "DELETE" }); + }, posts(q: string, limit: number, offset: number) { return listed(`/admin/posts${qs({ q, limit, offset })}`); },