From 1e23c07a207aded9c7d18945b484156674d07948 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 17 Jun 2026 14:36:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20slice=206a=20=E2=80=94=20User?= =?UTF-8?q?s=20directory=20surface=20+=20drawer=20(shadcn)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web UI rework. Slice 6a (force-split; 6b = compose dialog next): - UsersPage.impl.tsx directory surface off @mui: shadcn Table family + Checkbox + Badge (status: success=chart-2/destructive/secondary) + Avatar + Tooltip + Progress + Alert/Button/Stack/Typography - MUI Drawer -> shadcn Sheet side="right" for user detail drawer (buildUserDrawerModel rendering preserved) - Selection-across-pagination + search/filter parity preserved - Compose-dialog MUI subset (Dialog/TextField/Divider/IconButton + 9 icons) intentionally LEFT for slice 6b Gate: build + lint + test green. --- frontend/src/pages/UsersPage.impl.tsx | 1118 +++++++---------- .../src/pages/__tests__/UsersPage.test.tsx | 241 ++++ .../changes/web-ui-rework/apply-progress.md | 243 ++++ openspec/changes/web-ui-rework/tasks.md | 8 +- 4 files changed, 973 insertions(+), 637 deletions(-) create mode 100644 frontend/src/pages/__tests__/UsersPage.test.tsx diff --git a/frontend/src/pages/UsersPage.impl.tsx b/frontend/src/pages/UsersPage.impl.tsx index ae663c4..31e8f4c 100644 --- a/frontend/src/pages/UsersPage.impl.tsx +++ b/frontend/src/pages/UsersPage.impl.tsx @@ -1,6 +1,9 @@ -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import type { ChangeEvent } from "react"; +// Slice 6b owns the compose-dialog STRUCTURE + these icons: Dialog family, +// TextField, Divider, IconButton (below). Slice 6a migrated the directory +// surface + drawer AND the compose content's shared leaf components. import CloseIcon from "@mui/icons-material/Close"; import AttachFileIcon from "@mui/icons-material/AttachFile"; import FormatBoldIcon from "@mui/icons-material/FormatBold"; @@ -11,33 +14,39 @@ import MailOutlinedIcon from "@mui/icons-material/MailOutlined"; import SendIcon from "@mui/icons-material/Send"; import DeleteOutlinedIcon from "@mui/icons-material/DeleteOutlined"; import { - Avatar, - Alert, - Box, - Button, - Checkbox, - Chip, + // 6b-owned compose-dialog components only (the contract's narrow @mui set). + // Everything else — Alert/Box/Button/Chip/Paper/Stack/Typography/Tooltip/ + // LinearProgress/useMediaQuery — was migrated to shadcn/Tailwind in 6a. Dialog, DialogActions, DialogContent, DialogTitle, Divider, - Drawer, IconButton, - LinearProgress, - Paper, - Stack, + TextField, +} from "@mui/material"; +// 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 { Table, TableBody, TableCell, - TableContainer, TableHead, + TableHeader, TableRow, - TextField, - Tooltip, - Typography, - useMediaQuery, -} from "@mui/material"; +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; import { MetricCard } from "../components/MetricCard"; import { SessionActivityPanel } from "../components/SessionActivityPanel"; import { useUsers } from "../hooks/useUsers"; @@ -52,10 +61,43 @@ import { type UserStateItem, } from "../userState"; +// Replaces MUI `useMediaQuery` (a 6b-owned component) with a dependency-free +// matchMedia hook for the compose dialog's mobile fullScreen behavior. +function useIsMobile(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

"; export function UsersPage() { @@ -63,7 +105,7 @@ export function UsersPage() { const { data: activity } = useActivity(); const queueStatusQuery = useUserMessageQueueStatus(); const sendUserMessage = useSendUserMessage(); - const isMobile = useMediaQuery("(max-width: 900px)"); + const isMobile = useIsMobile(); const [search, setSearch] = useState(""); const [searchParams, setSearchParams] = useSearchParams(); const [selectedUserIds, setSelectedUserIds] = useState([]); @@ -188,9 +230,6 @@ export function UsersPage() { const allVisibleSelected = filteredRows.length > 0 && visibleSelectedRows.length === filteredRows.length; - const someVisibleSelected = - visibleSelectedRows.length > 0 && - visibleSelectedRows.length < filteredRows.length; const toggleUserSelected = (userId: string) => { setSelectedUserIds((current) => @@ -319,142 +358,114 @@ export function UsersPage() { } }; + // 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 - - +
+
+

Users

+

Read-only Jellyfin users with optional Jellyseerr enrichment. - - +

+
{isError ? ( - - Unable to load users: {(error as Error)?.message || "Unknown error"} - + + + 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. - + + + 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} - + + + 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. - + + + 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", - )} - + + + Unable to load email queue status:{" "} + {String( + (queueStatusQuery.error as Error)?.message || "Unknown error", + )} + + ) : queueBanner ? ( - - - - {queueBanner.message} - - - - - {queueBanner.subtext} - - + +
+ {queueBanner.message} + {queueBanner.countLabel} +
+ {queueBanner.subtext} +
) : null} - +
- +
- - - - - User list - +
+
+
+
+

User list

+

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

+
+ {selectedRows.length} selected + + {selectedDeliverableRows.length} deliverable + + + Message selected - - + setSearch(event.target.value)} sx={{ minWidth: { xs: "100%", sm: 320 } }} /> - - +
+
- - - +
+
+ - + - toggleVisibleSelection(event.target.checked) + aria-label="Select all visible users" + onCheckedChange={(checked) => + toggleVisibleSelection(checked === true) } - slotProps={{ - input: { "aria-label": "Select all visible users" }, - }} /> - - - User - - - Email - - + + User + Email + Activity - - + Type - - + + Jellyseerr - - + Role - - - Permissions - - + Permissions + Reqs - - + Contact - + - + {filteredRows.map((row) => { const linked = @@ -592,332 +541,226 @@ export function UsersPage() { const checked = selectedIdSet.has(row.jellyfin_id); return ( setSearchParams({ user: row.jellyfin_id })} > - + event.stopPropagation()} - onChange={() => toggleUserSelected(row.jellyfin_id)} - slotProps={{ - input: { "aria-label": `Select ${userLabel(row)}` }, - }} + onCheckedChange={() => + toggleUserSelected(row.jellyfin_id) + } /> - - - - {userLabel(row).charAt(0).toUpperCase()} + +
+ + + + {userLabel(row).charAt(0).toUpperCase()} + - - +
+
{userLabel(row)} - - +
+
{row.username && row.username !== row.display_name ? row.username : row.jellyfin_id} - - - +
+
+
- - + +
{row.email || "—"} - +
- - - - - - - - - - - - - - + - {row.permissions_label} - + {row.activity_label} + - - - {row.request_count ?? "—"} - + + {row.user_type_label} - - + + + {linked + ? `Linked #${row.jellyseerr_user_id}` + : "Base only"} + + + + {row.role} + + + {row.permissions_label} + + + {row.request_count ?? "—"} + + + + {row.contactable ? "Yes" : "No"} +
); })}
-
- - +
+
+
- setSearchParams({})} - slotProps={{ - paper: { - sx: { - width: { xs: "100%", sm: 440 }, - p: 3, - }, - }, + onOpenChange={(open) => { + if (!open) { + setSearchParams({}); + } }} > - {selectedUser && drawerModel ? ( - - - - {drawerModel.title.charAt(0).toUpperCase()} - - - - {drawerModel.title} - - - {drawerModel.subtitle} - - - - - + + {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) => ( - - +

Identity

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

Activity

+ +
+ +
+

Contact actions

+

+ {drawerModel.contactState.description} +

+
+ {drawerModel.contactActions.map((action) => ( + - {field.label} - - - {field.value} - - - ))} - - + {action.label} + + ))} +
+

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

+
- - - Activity - - - +
+

Permissions

+
+ {drawerModel.permissions.map((permission) => ( + + {permission} + + ))} +
+
- - - Contact actions - - - {drawerModel.contactState.description} - - - {drawerModel.contactActions.map((action) => ( - - ))} - - - {drawerModel.contactActions - .map((action) => action.hint) - .join(" ")} - - + - - - Permissions - - - {drawerModel.permissions.map((permission) => ( - - ))} - - +

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

+ + ) : null} +
+ - - - - This panel is read-only for now. Communication actions will be - added later without redesigning the list. - -
- ) : null} -
- - + Message selected users - {sendUserMessage.isPending ? : null} + {sendUserMessage.isPending ? ( + + ) : null} - +
{sendUserMessage.isError ? ( - - Unable to send message:{" "} - {(sendUserMessage.error as Error)?.message || "Unknown error"} - + + + 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)})` - : ""} - . - + + + 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.` - : ""} - + + + {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) => ( - `} - size="small" - /> + + {`${userLabel(row)} <${row.email}>`} + ))} - +
setSubject(event.target.value)} /> - - - insertMarkup("", "")} - > - - +
+ + + insertMarkup("", "")} + aria-label="Bold" + > + + + + Bold - - insertMarkup("", "")}> - - + + + insertMarkup("", "")} + aria-label="Italic" + > + + + + Italic - - - - + + + + + + + Link - - insertMarkup("
  • ", "
")} - > - -
+ + + insertMarkup("
  • ", "
")} + aria-label="Bullet list" + > + +
+
+ Bullet list
- +
- - - Preview - - +
+

Preview

+