Files
manage/frontend/src/pages/UsersPage.impl.tsx
T
Developer 09b9c45665 SheetForm dirty-state confirm + wire isDirty into all form consumers (R4.5)
SheetForm gains an isDirty prop. When true, any close attempt (Cancel
button, header X, Radix overlay click, Escape) opens a 'Discard changes?'
ConfirmDialog instead of discarding unsaved edits. Radix dismiss callbacks
(onEscapeKeyDown, onPointerDownOutside) are intercepted when dirty so the
guard applies uniformly.

All four form consumers now compute and pass isDirty:
- ServicePage: name/enabled/config differ from the persisted instance.
- Settings machine editor: field-by-field draft vs editingMachine
  (create mode is always dirty; secret write-only fields excluded).
- Message compose: subject non-empty, body differs from default, or
  attachments present.
- WidgetConfigDialog: draft !== null (only draft mode is guarded; list
  mode has nothing to discard).

Tests: 3 new SheetForm dirty-guard cases (prompt on cancel, abort discard,
clean close when not dirty) + one focused dirty-guard test per consumer.
122 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #1.
2026-06-26 15:31:30 +00:00

1085 lines
33 KiB
TypeScript

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 = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
// 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<UserStateItem>[] = [
{ 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) => (
<Badge variant={activityBadgeVariant(r.activity_label)}>
{r.activity_label}
</Badge>
),
},
{ 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<string[]>([]);
const [composeOpen, setComposeOpen] = useState(false);
const [subject, setSubject] = useState("");
const [htmlBody, setHtmlBody] = useState(DEFAULT_HTML_BODY);
const [attachments, setAttachments] = useState<File[]>([]);
const htmlBodyRef = useRef<HTMLTextAreaElement | null>(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(`<a href="${url}">`, "</a>");
};
const handleAttachments = (event: ChangeEvent<HTMLInputElement>) => {
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 (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-semibold">Users</h1>
<p className="text-sm text-muted-foreground">
Read-only Jellyfin users with optional Jellyseerr enrichment.
</p>
</div>
{isError ? (
<UIAlert variant="destructive">
<AlertDescription>
Unable to load users: {(error as Error)?.message || "Unknown error"}
</AlertDescription>
</UIAlert>
) : null}
{data && !data.jellyseerr_configured ? (
<UIAlert>
<AlertDescription>
Jellyseerr is not configured in the backend yet. Check
JELLYSEERR_URL and JELLYSEERR_API_KEY, then restart the API.
</AlertDescription>
</UIAlert>
) : null}
{data?.jellyseerr_error ? (
<UIAlert>
<AlertDescription>
Jellyseerr enrichment is unavailable: {data.jellyseerr_error}
</AlertDescription>
</UIAlert>
) : null}
{data?.jellyseerr_configured &&
!data.jellyseerr_error &&
data.enriched_count === 0 ? (
<UIAlert>
<AlertDescription>
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.
</AlertDescription>
</UIAlert>
) : null}
{queueStatusQuery.isError ? (
<UIAlert>
<AlertDescription>
Unable to load email queue status:{" "}
{String(
(queueStatusQuery.error as Error)?.message || "Unknown error",
)}
</AlertDescription>
</UIAlert>
) : queueBanner ? (
<UIAlert
variant={queueBanner.severity === "error" ? "destructive" : undefined}
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{queueBanner.message}</span>
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
</div>
<AlertDescription>{queueBanner.subtext}</AlertDescription>
</UIAlert>
) : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<MetricCard label="Total users" value={String(metrics.total)} />
<MetricCard label="Contactable" value={String(metrics.contactable)} />
<MetricCard label="Enriched" value={String(metrics.enriched)} />
<MetricCard label="Admins" value={String(metrics.admins)} />
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-base font-semibold">User list</h2>
<p className="text-sm text-muted-foreground">
{filteredRows.length} visible of {rows.length} total
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Badge variant="outline">{selectedRows.length} selected</Badge>
<Badge
variant={selectedDeliverableRows.length ? "success" : "outline"}
>
{selectedDeliverableRows.length} deliverable
</Badge>
<UiButton
variant="default"
disabled={!selectedDeliverableRows.length}
onClick={openCompose}
>
<Mail />
Message selected
</UiButton>
<UiButton
variant="ghost"
disabled={!selectedRows.length}
onClick={() => setSelectedUserIds([])}
>
Clear selection
</UiButton>
<Input
aria-label="Search"
placeholder="Name, email, role, permission..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="w-full sm:w-80"
/>
</div>
</div>
<div className="max-h-[660px] overflow-auto rounded-lg border">
{isMobile ? (
<div className="p-3">
<MobileCardRow
rows={filteredRows}
fields={userCardFields}
getRowId={(r) => r.jellyfin_id}
onRowClick={(r) => setSearchParams({ user: r.jellyfin_id })}
actions={(r) => {
const checked = selectedIdSet.has(r.jellyfin_id);
return (
<Checkbox
checked={checked}
aria-label={`Select ${userLabel(r)}`}
className="mobile-touch-target"
onClick={(e) => e.stopPropagation()}
onCheckedChange={() =>
toggleUserSelected(r.jellyfin_id)
}
/>
);
}}
/>
</div>
) : (
<Table aria-label="Users table">
<TableHeader>
<TableRow>
<TableHead className={cn(thBase, "w-14 p-2")}>
<Checkbox
checked={allVisibleSelected}
aria-label="Select all visible users"
onCheckedChange={(checked) =>
toggleVisibleSelection(checked === true)
}
/>
</TableHead>
<TableHead className={thBase}>User</TableHead>
<TableHead className={thBase}>Email</TableHead>
<TableHead className={cn(thBase, "w-[132px] text-center")}>
Activity
</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-[140px] text-center md:table-cell",
)}
>
Type
</TableHead>
<TableHead className={cn(thBase, "w-[132px] text-center")}>
Jellyseerr
</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-[120px] text-center md:table-cell",
)}
>
Role
</TableHead>
<TableHead className={thBase}>Permissions</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-24 text-center md:table-cell",
)}
>
Reqs
</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-[120px] text-center md:table-cell",
)}
>
Contact
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.map((row) => {
const linked =
row.jellyseerr_user_id !== null &&
row.jellyseerr_user_id !== undefined;
const checked = selectedIdSet.has(row.jellyfin_id);
return (
<TableRow
key={row.jellyfin_id}
data-state={
checked ||
selectedUser?.jellyfin_id === row.jellyfin_id
? "selected"
: undefined
}
className="cursor-pointer"
onClick={() =>
setSearchParams({ user: row.jellyfin_id })
}
>
<TableCell className="w-14 p-2">
<Checkbox
checked={checked}
aria-label={`Select ${userLabel(row)}`}
onClick={(event) => event.stopPropagation()}
onCheckedChange={() =>
toggleUserSelected(row.jellyfin_id)
}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-3 min-w-0">
<Avatar className="size-9">
<AvatarImage
src={row.avatar || undefined}
alt={userLabel(row)}
/>
<AvatarFallback>
{userLabel(row).charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="truncate font-semibold leading-tight">
{userLabel(row)}
</div>
<div className="truncate text-xs text-muted-foreground">
{row.username &&
row.username !== row.display_name
? row.username
: row.jellyfin_id}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="truncate font-medium">
{row.email || "—"}
</div>
</TableCell>
<TableCell className="text-center">
<Badge
variant={activityBadgeVariant(row.activity_label)}
>
{row.activity_label}
</Badge>
</TableCell>
<TableCell className="hidden text-center md:table-cell">
<Badge variant="outline">{row.user_type_label}</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant={linked ? "success" : "secondary"}>
{linked
? `Linked #${row.jellyseerr_user_id}`
: "Base only"}
</Badge>
</TableCell>
<TableCell className="hidden text-center md:table-cell">
<Badge variant="outline">{row.role}</Badge>
</TableCell>
<TableCell className="whitespace-normal">
{row.permissions_label}
</TableCell>
<TableCell className="hidden text-center font-semibold md:table-cell">
{row.request_count ?? "—"}
</TableCell>
<TableCell className="hidden text-center md:table-cell">
<Badge
variant={row.contactable ? "success" : "secondary"}
>
{row.contactable ? "Yes" : "No"}
</Badge>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</div>
</div>
<Sheet
open={Boolean(drawerModel)}
onOpenChange={(open) => {
if (!open) {
setSearchParams({});
}
}}
>
<SheetContent
side="right"
showCloseButton={false}
className="w-full gap-6 overflow-y-auto p-6 sm:max-w-[440px]"
>
{selectedUser && drawerModel ? (
<div className="flex flex-col gap-6">
<div className="flex items-start gap-4">
<Avatar className="size-14">
<AvatarImage
src={selectedUser.avatar || undefined}
alt={drawerModel.title}
/>
<AvatarFallback>
{drawerModel.title.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<h2 className="truncate text-lg font-bold">
{drawerModel.title}
</h2>
<p className="truncate text-sm text-muted-foreground">
{drawerModel.subtitle}
</p>
</div>
<Badge variant="secondary">
{drawerModel.contactState.label}
</Badge>
<UiButton
variant="ghost"
aria-label="Close user details"
onClick={() => setSearchParams({})}
>
<X />
Close
</UiButton>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{selectedUser.user_type_label}</Badge>
<Badge variant="default">{selectedUser.role}</Badge>
<Badge variant="secondary">{drawerModel.syncStatus}</Badge>
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-2 text-sm font-semibold">Identity</h3>
<div className="flex flex-col gap-1">
{drawerModel.identity.map((field) => (
<div key={field.label} className="flex gap-4">
<span className="min-w-[120px] text-xs uppercase text-muted-foreground">
{field.label}
</span>
<span className="break-words text-sm">{field.value}</span>
</div>
))}
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-2 text-sm font-semibold">Activity</h3>
<SessionActivityPanel
sessions={selectedUser.activity.sessions}
selectedUserLabel={
selectedUser.display_name ||
selectedUser.username ||
selectedUser.jellyfin_id
}
emptyMessage="No live sessions matched to this user."
/>
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-2 text-sm font-semibold">Contact actions</h3>
<p className="mb-2 text-sm text-muted-foreground">
{drawerModel.contactState.description}
</p>
<div className="flex flex-wrap gap-2">
{drawerModel.contactActions.map((action) => (
<UiButton
key={action.label}
variant="outline"
disabled={!action.enabled}
>
{action.label}
</UiButton>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
{drawerModel.contactActions
.map((action) => action.hint)
.join(" ")}
</p>
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-2 text-sm font-semibold">Permissions</h3>
<div className="flex flex-wrap gap-1">
{drawerModel.permissions.map((permission) => (
<Badge key={permission} variant="secondary">
{permission}
</Badge>
))}
</div>
</div>
<Separator />
<p className="text-xs text-muted-foreground">
This panel is read-only for now. Communication actions will be
added later without redesigning the list.
</p>
</div>
) : null}
</SheetContent>
</Sheet>
{/* Compose dialog: SheetForm below md, Dialog at md+ (spec R4.1) */}
{(() => {
const composeBody = (
<>
{sendUserMessage.isPending ? (
<Progress value={100} className="animate-pulse" />
) : null}
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
{sendUserMessage.isError ? (
<UIAlert variant="destructive">
<AlertDescription>
Unable to send message:{" "}
{(sendUserMessage.error as Error)?.message ||
"Unknown error"}
</AlertDescription>
</UIAlert>
) : null}
{sendUserMessage.isSuccess ? (
<UIAlert>
<AlertDescription>
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)})`
: ""}
.
</AlertDescription>
</UIAlert>
) : null}
{queueBanner ? (
<UIAlert
variant={
queueBanner.severity === "error" ? "destructive" : undefined
}
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">
{queueBanner.message}
</span>
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
</div>
</UIAlert>
) : null}
<UIAlert>
<AlertDescription>
{selectedRows.length} selected,{" "}
{selectedDeliverableRows.length} deliverable.
{skippedRows.length
? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
: ""}
</AlertDescription>
</UIAlert>
<div className="flex flex-wrap gap-1">
{selectedDeliverableRows.map((row) => (
<Badge key={row.jellyfin_id} variant="secondary">
{`${userLabel(row)} <${row.email}>`}
</Badge>
))}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="compose-subject">Subject</Label>
<Input
id="compose-subject"
value={subject}
onChange={(event) => setSubject(event.target.value)}
/>
</div>
<div className="flex flex-wrap gap-1">
<Tooltip>
<TooltipTrigger asChild>
<UiButton
variant="ghost"
size="icon"
className="mobile-touch-target"
onClick={() => insertMarkup("<strong>", "</strong>")}
aria-label="Bold"
>
<Bold />
</UiButton>
</TooltipTrigger>
<TooltipContent>Bold</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<UiButton
variant="ghost"
size="icon"
className="mobile-touch-target"
onClick={() => insertMarkup("<em>", "</em>")}
aria-label="Italic"
>
<Italic />
</UiButton>
</TooltipTrigger>
<TooltipContent>Italic</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<UiButton
variant="ghost"
size="icon"
className="mobile-touch-target"
onClick={addLink}
aria-label="Link"
>
<Link />
</UiButton>
</TooltipTrigger>
<TooltipContent>Link</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<UiButton
variant="ghost"
size="icon"
className="mobile-touch-target"
onClick={() => insertMarkup("<ul><li>", "</li></ul>")}
aria-label="Bullet list"
>
<List />
</UiButton>
</TooltipTrigger>
<TooltipContent>Bullet list</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="compose-body">HTML message body</Label>
<Textarea
id="compose-body"
ref={htmlBodyRef}
value={htmlBody}
onChange={(event) => setHtmlBody(event.target.value)}
className="min-h-[260px] font-mono"
/>
<p className="text-xs text-muted-foreground">
Formatting is sent as HTML; a plain-text fallback is generated
automatically.
</p>
</div>
<div className="rounded-lg border bg-muted/40 p-4">
<p className="mb-2 text-sm font-semibold">Preview</p>
<div className="overflow-hidden rounded-md border bg-card">
<iframe
title="Email preview"
sandbox=""
srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:Roboto,Arial,sans-serif;padding:16px;margin:0;background:#fff;color:#111;line-height:1.5}</style></head><body>${htmlBody || "<p>(Empty)</p>"}</body></html>`}
style={{ width: "100%", minHeight: 220, border: 0 }}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-1">
<UiButton asChild variant="outline">
<label className="cursor-pointer">
<Paperclip />
Add attachment
<input
hidden
type="file"
multiple
onChange={handleAttachments}
/>
</label>
</UiButton>
{attachments.map((file, index) => (
<Badge
key={`${file.name}-${index}`}
variant="secondary"
className="gap-1 pr-1"
>
{file.name}
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => removeAttachment(index)}
className="mobile-touch-target inline-flex items-center text-current [&>svg]:size-3"
>
<Trash2 />
</button>
</Badge>
))}
</div>
</div>
</>
);
if (isMobile) {
return (
<SheetForm
open={composeOpen}
onOpenChange={(open) => {
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
}
>
<div className="flex flex-col gap-4">{composeBody}</div>
</SheetForm>
);
}
return (
<Dialog
open={composeOpen}
onOpenChange={(open) => {
if (!open) {
closeCompose();
}
}}
>
<DialogContent
className={cn(
"flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
isComposeMobile &&
"inset-0 max-h-none max-w-none translate-x-0 translate-y-0 rounded-none",
)}
>
<DialogHeader className="gap-1 px-4 pt-4">
<DialogTitle className="pr-8">
Message selected users
</DialogTitle>
<DialogDescription className="sr-only">
Compose a message to the selected deliverable users.
</DialogDescription>
</DialogHeader>
{composeBody}
<DialogFooter className="m-0 border-t p-4">
<UiButton variant="ghost" onClick={closeCompose}>
Cancel
</UiButton>
<UiButton
variant="default"
disabled={
sendUserMessage.isPending ||
!selectedDeliverableRows.length ||
!subject.trim()
}
onClick={handleSend}
>
<Send />
Send message
</UiButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
})()}
</div>
);
}