import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import type { ChangeEvent } from "react"; // Slice 6b: compose dialog (shadcn Dialog family) + lucide icons. The file is // now fully @mui-free (6a migrated the directory surface, drawer, and the // compose content's shared leaf components). import { X, Paperclip, Bold, Italic, Link, List, Mail, Send, Trash2, } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Separator } from "@/components/ui/separator"; import { Label } from "@/components/ui/label"; // Slice 6a directory surface + drawer primitives. import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button as UiButton } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Alert as UIAlert, AlertDescription } from "@/components/ui/alert"; import { Progress } from "@/components/ui/progress"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { SheetForm } from "@/components/ui/sheet-form"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { cn } from "@/lib/utils"; import { MetricCard } from "../components/MetricCard"; import { SessionActivityPanel } from "../components/SessionActivityPanel"; import { MobileCardRow, type MobileCardField, } from "@/components/ui/mobile-card"; import { useUsers } from "../hooks/useUsers"; import { useActivity } from "../hooks/useDashboard"; import { useIsMobile } from "../hooks/useIsMobile"; import { useSendUserMessage } from "../hooks/useSendUserMessage"; import { useUserMessageQueueStatus } from "../hooks/useUserMessageQueueStatus"; import type { UserDirectoryItem } from "../types"; import { buildUserDrawerModel } from "../users"; import { mergeUsersWithActivity, resolveUserSelection, type UserStateItem, } from "../userState"; // Local breakpoint for the compose dialog (slice 6b uses 900px for fullScreen). // The shared `useIsMobile` from hooks/ (768px) drives the directory table branch. function useComposeViewport(query = "(max-width: 900px)") { const [mobile, setMobile] = useState(() => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(query).matches : false, ); useEffect(() => { if ( typeof window === "undefined" || typeof window.matchMedia !== "function" ) { return; } const mql = window.matchMedia(query); const onChange = (event: MediaQueryListEvent) => setMobile(event.matches); mql.addEventListener("change", onChange); return () => mql.removeEventListener("change", onChange); }, [query]); return mobile; } function userLabel(user: UserDirectoryItem) { return user.display_name || user.username || user.jellyfin_id; } // Activity → Badge status variant (design §2.3: healthy/active = success chart-2, // paused = warning chart-3, neutral = secondary). function activityBadgeVariant( label: string, ): "success" | "warning" | "secondary" { if (label === "Playing") return "success"; if (label === "Paused") return "warning"; return "secondary"; } const DEFAULT_HTML_BODY = "

Hello,

Best,
Manage

"; // Mobile card fields (spec R3.2): display name is primary; username, activity // badge, and email give the at-a-glance info for scanning users on a phone. // See OpenSpec change `mobile-responsive-parity`, tasks slice 5.1. const userCardFields: MobileCardField[] = [ { key: "name", label: "Name", render: (r) => userLabel(r), primary: true }, { key: "username", label: "Username", render: (r) => r.username && r.username !== r.display_name ? r.username : r.jellyfin_id, }, { key: "activity", label: "Activity", render: (r) => ( {r.activity_label} ), }, { key: "email", label: "Email", render: (r) => r.email || "—" }, ]; export function UsersPage() { const { data, isError, error } = useUsers(); const { data: activity } = useActivity(); const queueStatusQuery = useUserMessageQueueStatus(); const sendUserMessage = useSendUserMessage(); const isComposeMobile = useComposeViewport(); const isMobile = useIsMobile(); const [search, setSearch] = useState(""); const [searchParams, setSearchParams] = useSearchParams(); const [selectedUserIds, setSelectedUserIds] = useState([]); const [composeOpen, setComposeOpen] = useState(false); const [subject, setSubject] = useState(""); const [htmlBody, setHtmlBody] = useState(DEFAULT_HTML_BODY); const [attachments, setAttachments] = useState([]); const htmlBodyRef = useRef(null); const baseRows = data?.items ?? []; const rows = useMemo( () => mergeUsersWithActivity(baseRows, activity ?? []), [baseRows, activity], ); const filteredRows = useMemo(() => { const term = search.trim().toLowerCase(); if (!term) { return rows; } return rows.filter((row) => { return [ row.username, row.display_name, row.email, row.email_source, row.avatar_source, row.name_source, row.access_source, row.user_type_label, row.role, row.permissions_label, row.jellyseerr_username, row.activity_label, row.activity_summary, row.activity.primary_session?.title || "", String(row.jellyseerr_user_id ?? ""), ].some((value) => value.toLowerCase().includes(term)); }); }, [rows, search]); const metrics = useMemo(() => { const total = baseRows.length; const contactable = rows.filter((row) => row.contactable).length; const enriched = rows.filter( (row) => row.jellyseerr_user_id !== null, ).length; const admins = rows.filter((row) => row.role === "admin").length; return { total, contactable, enriched, admins }; }, [baseRows]); const queueStatus = queueStatusQuery.data; const queueBanner = useMemo(() => { if (!queueStatus) { return null; } const activeCount = queueStatus.active_request_id ? 1 : 0; const totalCount = queueStatus.pending_count + activeCount; const countLabel = totalCount > 0 ? `${totalCount} item${totalCount === 1 ? "" : "s"} in queue (${queueStatus.pending_count} waiting${activeCount ? ", 1 processing" : ""})` : "0 items in queue"; if (!queueStatus.worker_running) { return { severity: "warning" as const, message: queueStatus.last_error || "Email queue worker is not running. New messages cannot be delivered until it restarts.", countLabel, subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`, }; } if (queueStatus.state === "error") { return { severity: "error" as const, message: queueStatus.last_error || "The last email delivery failed.", countLabel, subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`, }; } if (queueStatus.state === "busy") { const active = queueStatus.active_request_id ? `processing ${queueStatus.active_request_id.slice(0, 8)}` : "processing a message"; const waiting = queueStatus.pending_count ? `${queueStatus.pending_count} waiting` : "no backlog"; return { severity: "info" as const, message: `Email queue is busy: ${active}, ${waiting}.`, countLabel, subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`, }; } return { severity: "success" as const, message: "Email queue is idle and empty.", countLabel, subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`, }; }, [queueStatus]); const selectedIdSet = useMemo( () => new Set(selectedUserIds), [selectedUserIds], ); const selectedRows = useMemo( () => rows.filter((row) => selectedIdSet.has(row.jellyfin_id)), [rows, selectedIdSet], ); const selectedDeliverableRows = useMemo( () => selectedRows.filter((row) => row.contactable && row.email), [selectedRows], ); const skippedRows = useMemo( () => selectedRows.filter((row) => !row.contactable || !row.email), [selectedRows], ); const visibleSelectedRows = useMemo( () => filteredRows.filter((row) => selectedIdSet.has(row.jellyfin_id)), [filteredRows, selectedIdSet], ); const allVisibleSelected = filteredRows.length > 0 && visibleSelectedRows.length === filteredRows.length; const toggleUserSelected = (userId: string) => { setSelectedUserIds((current) => current.includes(userId) ? current.filter((id) => id !== userId) : [...current, userId], ); }; const toggleVisibleSelection = (checked: boolean) => { setSelectedUserIds((current) => { const next = new Set(current); filteredRows.forEach((row) => { if (checked) { next.add(row.jellyfin_id); } else { next.delete(row.jellyfin_id); } }); return Array.from(next); }); }; const selectedUserParam = searchParams.get("user") || ""; const selectedUser = useMemo( () => selectedUserParam ? (resolveUserSelection( rows, selectedUserParam, ) as UserStateItem | null) : null, [rows, selectedUserParam], ); const drawerModel = selectedUser ? buildUserDrawerModel(selectedUser) : null; const openCompose = () => { if (!selectedRows.length) { return; } sendUserMessage.reset(); if (!subject.trim()) { setSubject( `Manage update for ${selectedDeliverableRows.length} user${selectedDeliverableRows.length === 1 ? "" : "s"}`, ); } if (!htmlBody.trim()) { setHtmlBody(DEFAULT_HTML_BODY); } setComposeOpen(true); }; const closeCompose = () => { setComposeOpen(false); sendUserMessage.reset(); }; const insertMarkup = (before: string, after = before) => { const textarea = htmlBodyRef.current; if (!textarea) { return; } const start = textarea.selectionStart ?? htmlBody.length; const end = textarea.selectionEnd ?? htmlBody.length; const selected = htmlBody.slice(start, end) || "text"; const next = htmlBody.slice(0, start) + before + selected + after + htmlBody.slice(end); setHtmlBody(next); requestAnimationFrame(() => { textarea.focus(); const cursorStart = start + before.length; const cursorEnd = cursorStart + selected.length; textarea.setSelectionRange(cursorStart, cursorEnd); }); }; const addLink = () => { const url = window.prompt("Link URL", "https://"); if (!url) { return; } insertMarkup(``, ""); }; const handleAttachments = (event: ChangeEvent) => { const files = Array.from(event.target.files || []); if (files.length) { setAttachments((current) => [...current, ...files]); } event.target.value = ""; }; const removeAttachment = (index: number) => { setAttachments((current) => current.filter((_, idx) => idx !== index)); }; const handleSend = async () => { const allSelectedRows = selectedRows; if (!allSelectedRows.length) { return; } const formData = new FormData(); formData.append( "recipient_ids", JSON.stringify(allSelectedRows.map((row) => row.jellyfin_id)), ); formData.append("subject", subject); formData.append("html_body", htmlBody); attachments.forEach((file) => { formData.append("attachments", file, file.name); }); try { await sendUserMessage.mutateAsync(formData); setComposeOpen(false); setAttachments([]); setSubject(""); setHtmlBody(DEFAULT_HTML_BODY); } catch { // Mutation state is shown inline. } }; // Sticky table-header base (opaque so rows don't bleed through on scroll). const thBase = "font-semibold sticky top-0 z-10 bg-card"; return (

Users

Read-only Jellyfin users with optional Jellyseerr enrichment.

{isError ? ( Unable to load users: {(error as Error)?.message || "Unknown error"} ) : null} {data && !data.jellyseerr_configured ? ( Jellyseerr is not configured in the backend yet. Check JELLYSEERR_URL and JELLYSEERR_API_KEY, then restart the API. ) : null} {data?.jellyseerr_error ? ( Jellyseerr enrichment is unavailable: {data.jellyseerr_error} ) : null} {data?.jellyseerr_configured && !data.jellyseerr_error && data.enriched_count === 0 ? ( Jellyseerr is connected, but no Jellyfin users were matched yet. The backend found {data.jellyseerr_jellyfin_user_count} Jellyfin-linked entries and {data.jellyseerr_user_count} Jellyseerr users. ) : null} {queueStatusQuery.isError ? ( Unable to load email queue status:{" "} {String( (queueStatusQuery.error as Error)?.message || "Unknown error", )} ) : queueBanner ? (
{queueBanner.message} {queueBanner.countLabel}
{queueBanner.subtext}
) : null}

User list

{filteredRows.length} visible of {rows.length} total

{selectedRows.length} selected {selectedDeliverableRows.length} deliverable Message selected setSelectedUserIds([])} > Clear selection setSearch(event.target.value)} className="w-full sm:w-80" />
{isMobile ? (
r.jellyfin_id} onRowClick={(r) => setSearchParams({ user: r.jellyfin_id })} actions={(r) => { const checked = selectedIdSet.has(r.jellyfin_id); return ( e.stopPropagation()} onCheckedChange={() => toggleUserSelected(r.jellyfin_id) } /> ); }} />
) : ( toggleVisibleSelection(checked === true) } /> User Email Activity Type Jellyseerr Role Permissions Reqs Contact {filteredRows.map((row) => { const linked = row.jellyseerr_user_id !== null && row.jellyseerr_user_id !== undefined; const checked = selectedIdSet.has(row.jellyfin_id); return ( setSearchParams({ user: row.jellyfin_id }) } > event.stopPropagation()} onCheckedChange={() => toggleUserSelected(row.jellyfin_id) } />
{userLabel(row).charAt(0).toUpperCase()}
{userLabel(row)}
{row.username && row.username !== row.display_name ? row.username : row.jellyfin_id}
{row.email || "—"}
{row.activity_label} {row.user_type_label} {linked ? `Linked #${row.jellyseerr_user_id}` : "Base only"} {row.role} {row.permissions_label} {row.request_count ?? "—"} {row.contactable ? "Yes" : "No"}
); })}
)}
{ if (!open) { setSearchParams({}); } }} > {selectedUser && drawerModel ? (
{drawerModel.title.charAt(0).toUpperCase()}

{drawerModel.title}

{drawerModel.subtitle}

{drawerModel.contactState.label} setSearchParams({})} > Close
{selectedUser.user_type_label} {selectedUser.role} {drawerModel.syncStatus}

Identity

{drawerModel.identity.map((field) => (
{field.label} {field.value}
))}

Activity

Contact actions

{drawerModel.contactState.description}

{drawerModel.contactActions.map((action) => ( {action.label} ))}

{drawerModel.contactActions .map((action) => action.hint) .join(" ")}

Permissions

{drawerModel.permissions.map((permission) => ( {permission} ))}

This panel is read-only for now. Communication actions will be added later without redesigning the list.

) : null}
{/* Compose dialog: SheetForm below md, Dialog at md+ (spec R4.1) */} {(() => { const composeBody = ( <> {sendUserMessage.isPending ? ( ) : null}
{sendUserMessage.isError ? ( Unable to send message:{" "} {(sendUserMessage.error as Error)?.message || "Unknown error"} ) : null} {sendUserMessage.isSuccess ? ( Queued for {sendUserMessage.data.recipient_count} recipients {sendUserMessage.data.attachment_count ? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}` : ""} {sendUserMessage.data.request_id ? ` (request ${sendUserMessage.data.request_id.slice(0, 8)})` : ""} . ) : null} {queueBanner ? (
{queueBanner.message} {queueBanner.countLabel}
) : null} {selectedRows.length} selected,{" "} {selectedDeliverableRows.length} deliverable. {skippedRows.length ? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.` : ""}
{selectedDeliverableRows.map((row) => ( {`${userLabel(row)} <${row.email}>`} ))}
setSubject(event.target.value)} />
insertMarkup("", "")} aria-label="Bold" > Bold insertMarkup("", "")} aria-label="Italic" > Italic Link insertMarkup("
  • ", "
")} aria-label="Bullet list" >
Bullet list