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}
{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}>`}
))}
Subject
setSubject(event.target.value)}
/>
insertMarkup("", " ")}
aria-label="Bold"
>
Bold
insertMarkup("", " ")}
aria-label="Italic"
>
Italic
Link
insertMarkup("")}
aria-label="Bullet list"
>
Bullet list
>
);
if (isMobile) {
return (
{
if (!open) closeCompose();
}}
title="Message selected users"
onSave={handleSend}
onCancel={closeCompose}
isPending={sendUserMessage.isPending}
saveDisabled={!selectedDeliverableRows.length || !subject.trim()}
saveLabel="Send message"
isDirty={
subject.trim() !== "" ||
htmlBody.trim() !== DEFAULT_HTML_BODY.trim() ||
attachments.length > 0
}
>
{composeBody}
);
}
return (
{
if (!open) {
closeCompose();
}
}}
>
Message selected users
Compose a message to the selected deliverable users.
{composeBody}
Cancel
Send message
);
})()}
);
}