Files
Magent/frontend/app/portal/PortalClient.tsx
T
Assclaw f852e7c941
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped
chore: standardize security and quality foundations
2026-09-17 20:03:47 +12:00

2745 lines
113 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import ResolutionChoice from "../ui/ResolutionChoice";
import PageHeading from "../ui/PageHeading";
import IssueFlowStep from "./IssueFlowStep";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type PortalPermissions = {
can_edit?: boolean;
can_comment?: boolean;
can_moderate?: boolean;
can_delete?: boolean;
can_confirm_resolution?: boolean;
};
type PortalItem = {
id: number;
kind: "request" | "issue" | "feature";
title: string;
description: string;
media_type?: "movie" | "tv" | null;
year?: number | null;
external_ref?: string | null;
source_system?: string | null;
source_request_id?: number | null;
status: string;
priority: string;
created_by_username: string;
assignee_username?: string | null;
created_at: string;
updated_at: string;
last_activity_at: string;
permissions?: PortalPermissions;
workflow?: {
request_status?: string;
media_status?: string;
stage_label?: string;
is_terminal?: boolean;
};
issue?: {
issue_type?: string;
related_item_id?: number | null;
is_resolved?: boolean;
resolved_at?: string | null;
workflow?: {
current_step?: number;
total_steps?: number;
stage?: string;
stage_label?: string;
headline?: string;
message?: string;
state?: "active" | "attention" | "complete";
steps?: Array<{
key: string;
label: string;
state: "waiting" | "active" | "attention" | "complete";
}>;
};
confirmation?: {
status?: string | null;
attempts_sent?: number;
maximum_attempts?: number;
last_contact_at?: string | null;
next_contact_at?: string | null;
interval_value?: number | null;
interval_unit?: string | null;
last_delivery_succeeded?: boolean | null;
};
};
};
type PortalComment = {
id: number;
item_id: number;
author_username: string;
author_role: string;
message: string;
is_internal: boolean;
created_at: string;
};
type PortalActivity = {
id: number | string;
item_id: number;
event_type: string;
actor_username: string;
actor_role: string;
message: string;
created_at: string;
};
type PortalOverview = {
overview?: {
total_items?: number;
total_comments?: number;
by_kind?: Record<string, number>;
by_status?: Record<string, number>;
};
my_items?: number;
};
type UserProfile = {
username: string;
role: string;
};
type DiscoveryResult = {
title: string;
year?: number | null;
type?: "movie" | "tv" | null;
tmdbId?: number | null;
requestId?: number | null;
statusLabel?: string | null;
status?: number | null;
accessible?: boolean;
posterPath?: string | null;
backdropPath?: string | null;
};
type IssueCategoryId =
| "broken_media"
| "wrong_content"
| "missing_content"
| "audio"
| "subtitle"
| "playback"
| "service_unavailable";
type MediaServerStatus = {
checked_at?: string;
status: "up" | "degraded" | "down" | "not_configured";
headline: string;
message: string;
latency_ms?: number | null;
server?: {
version?: string | null;
restart_pending?: boolean | null;
};
activity?: {
active_streams?: number | null;
transcoding_streams?: number | null;
available?: boolean;
};
};
type IssueEpisodeOption = {
id: number;
season_number: number;
episode_number: number;
code: string;
title: string;
released: boolean;
monitored: boolean;
has_file: boolean;
missing: boolean;
best_fit: boolean;
file_id?: number | null;
};
type IssueSeasonOption = {
season_number: number;
label: string;
episode_count: number;
available_count: number;
missing_count: number;
best_fit: boolean;
};
type IssueTargetOptions = {
request_id: string;
request_type: "movie" | "tv";
title: string;
collector_id?: number | null;
movie?: {
selected_label: string;
has_file: boolean;
missing: boolean;
best_fit: boolean;
file_id?: number | null;
} | null;
seasons: IssueSeasonOption[];
episodes: IssueEpisodeOption[];
can_act: boolean;
message?: string;
};
const ISSUE_CATEGORIES: Array<{
id: IssueCategoryId;
marker: string;
label: string;
description: string;
outcome: string;
issueType: string;
titlePrefix: string;
}> = [
{
id: "broken_media",
marker: "REPLACE",
label: "Picture or file is broken",
description: "Corruption, visual artefacts, freezing, or playback stopping at the same point.",
outcome: "The affected file will be replaced automatically.",
issueType: "broken_media",
titlePrefix: "Replace media",
},
{
id: "wrong_content",
marker: "WRONG FILE",
label: "Wrong thing downloaded",
description: "The movie, episode, cut, or edition does not match what it should be.",
outcome: "The incorrectly matched file will be replaced automatically.",
issueType: "wrong_content",
titlePrefix: "Wrong download",
},
{
id: "missing_content",
marker: "MISSING",
label: "Movie or episode is missing",
description: "A title, season, episode, or expected part is not available in Grizzlyflix.",
outcome: "The selected missing content will be sent back to Sonarr or Radarr.",
issueType: "missing_content",
titlePrefix: "Missing content",
},
{
id: "audio",
marker: "AUDIO",
label: "Audio is wrong",
description: "No sound, wrong language, commentary only, distorted audio, or audio out of sync.",
outcome: "The affected file will be replaced automatically.",
issueType: "audio",
titlePrefix: "Audio problem",
},
{
id: "subtitle",
marker: "SUBS",
label: "Subtitles are wrong",
description: "Missing, incorrect, forced, unreadable, or out-of-sync subtitles.",
outcome: "Bazarr will find a fresh subtitle without replacing the video.",
issueType: "subtitle",
titlePrefix: "Subtitle problem",
},
{
id: "playback",
marker: "PLAYBACK",
label: "Playback or transcoding problem",
description: "The title will not start, constantly buffers, stops, or reports a transcode error.",
outcome: "Magent will check Jellyfin and replace only the selected file when appropriate.",
issueType: "playback",
titlePrefix: "Playback problem",
},
{
id: "service_unavailable",
marker: "SERVER",
label: "Nothing will play",
description: "Grizzlyflix will not open or every title fails across the device or household.",
outcome: "Magent will check Jellyfin and attach the result to the issue.",
issueType: "service_unavailable",
titlePrefix: "Media server unavailable",
},
];
const ISSUE_SYMPTOMS: Record<IssueCategoryId, string[]> = {
broken_media: [
"Visual artefacts or corruption",
"Freezes at the same point",
"Stops before the end",
"File will not play",
],
wrong_content: [
"Different movie or show",
"Wrong episode",
"Episodes are labelled incorrectly",
"Wrong cut or edition",
],
missing_content: ["Entire title is missing", "Season is missing", "Episode is missing", "Part or edition is missing"],
audio: ["No audio", "Wrong language", "Commentary track only", "Audio is out of sync", "Audio is distorted"],
subtitle: ["Subtitles are missing", "Wrong subtitles", "Subtitles are out of sync", "Forced subtitles are missing"],
playback: [
"Will not start",
"Constant buffering",
"Transcode error",
"Stops during playback",
"Only fails on one device",
],
service_unavailable: [
"Grizzlyflix will not open",
"Every title fails",
"Login works but playback does not",
"Server error is shown",
],
};
const DEVICE_OPTIONS = ["TV app", "Web browser", "Phone or tablet", "Multiple devices"] as const;
type IssueStep = "problem" | "media" | "symptoms" | "targets" | "devices" | "review";
const ISSUE_TYPE_LABELS: Record<string, string> = {
general: "General",
playback: "Playback",
transcode: "Transcoding",
service_unavailable: "Server unavailable",
broken_media: "Broken media",
wrong_content: "Wrong download",
missing_content: "Missing content",
audio: "Audio",
subtitle: "Subtitles",
quality: "Quality",
metadata: "Metadata",
other: "Other",
};
const STATUS_OPTIONS = [
{ value: "new", label: "New" },
{ value: "triaging", label: "Triaging" },
{ value: "planned", label: "Planned" },
{ value: "in_progress", label: "In progress" },
{ value: "blocked", label: "Blocked" },
{ value: "awaiting_confirmation", label: "Fixed - ask reporter to confirm" },
{ value: "done", label: "Resolved (legacy)" },
{ value: "pending", label: "Pending approval" },
{ value: "approved", label: "Approved" },
{ value: "processing", label: "Processing" },
{ value: "partially_available", label: "Partially available" },
{ value: "available", label: "Available" },
{ value: "failed", label: "Failed" },
{ value: "declined", label: "Declined" },
{ value: "closed", label: "Closed" },
] as const;
const REQUEST_STATUS_OPTIONS = [
{ value: "pending", label: "Pending approval" },
{ value: "approved", label: "Approved" },
{ value: "declined", label: "Declined" },
] as const;
const MEDIA_STATUS_OPTIONS = [
{ value: "pending", label: "Pending" },
{ value: "processing", label: "Processing" },
{ value: "partially_available", label: "Partially available" },
{ value: "available", label: "Available" },
{ value: "failed", label: "Failed" },
{ value: "unknown", label: "Unknown" },
] as const;
const PRIORITY_OPTIONS = [
{ value: "low", label: "Low" },
{ value: "normal", label: "Normal" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
] as const;
const MEDIA_TYPE_OPTIONS = [
{ value: "", label: "None" },
{ value: "movie", label: "Movie" },
{ value: "tv", label: "TV" },
] as const;
const REQUEST_FILTER_STATUS_OPTIONS = [
{ value: "pending", label: "Pending approval" },
{ value: "approved", label: "Approved" },
{ value: "processing", label: "Processing" },
{ value: "partially_available", label: "Partially available" },
{ value: "available", label: "Available" },
{ value: "failed", label: "Failed" },
{ value: "declined", label: "Declined" },
] as const;
const ISSUE_FILTER_STATUS_OPTIONS = [
{ value: "new", label: "New" },
{ value: "triaging", label: "Triaging" },
{ value: "planned", label: "Planned" },
{ value: "in_progress", label: "In progress" },
{ value: "blocked", label: "Blocked" },
{ value: "awaiting_confirmation", label: "Waiting for confirmation" },
{ value: "done", label: "Previously resolved" },
{ value: "closed", label: "Closed" },
] as const;
const formatDate = (value?: string | null) => {
if (!value) return "Never";
const parsed = new Date(value);
if (Number.isNaN(parsed.valueOf())) return value;
return parsed.toLocaleString();
};
const formatIssueStatus = (value?: string | null) => {
const labels: Record<string, string> = {
new: "New",
triaging: "Triaging",
planned: "Planned",
in_progress: "In progress",
blocked: "Blocked",
awaiting_confirmation: "Waiting for reporter confirmation",
done: "Resolved",
closed: "Closed",
};
return labels[String(value ?? "").toLowerCase()] ?? String(value ?? "Unknown").replaceAll("_", " ");
};
function IssuePipeline({ item, compact = false }: { item: PortalItem; compact?: boolean }) {
const workflow = item.issue?.workflow;
const steps = workflow?.steps ?? [];
const currentStep = workflow?.current_step ?? 1;
const totalSteps = workflow?.total_steps ?? 6;
const state = workflow?.state ?? "active";
if (compact) {
return (
<div className={`issue-card-progress is-${state}`}>
<span>
<strong>{workflow?.stage_label ?? formatIssueStatus(item.status)}</strong>
<small>
Step {currentStep} of {totalSteps}
</small>
</span>
<i aria-hidden="true">
<b style={{ width: `${Math.max(0, Math.min(100, (currentStep / totalSteps) * 100))}%` }} />
</i>
</div>
);
}
return (
<section className={`issue-pipeline-card is-${state}`} aria-label="Issue progress">
<header>
<div>
<span className="section-kicker">Issue progress</span>
<h3>{workflow?.headline ?? formatIssueStatus(item.status)}</h3>
<p>{workflow?.message ?? "The support team will update this issue as work progresses."}</p>
</div>
<span className="small-pill">
Step {currentStep} of {totalSteps}
</span>
</header>
<ol>
{steps.map((step, index) => (
<li
key={step.key}
className={`is-${step.state}`}
aria-current={step.state === "active" || step.state === "attention" ? "step" : undefined}
>
<i aria-hidden="true">{step.state === "complete" ? "✓" : index + 1}</i>
<span>{step.label}</span>
</li>
))}
</ol>
</section>
);
}
const toPositiveInt = (value: string) => {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed <= 0) return null;
return parsed;
};
type PortalWorkspace = "request" | "issue";
type PortalClientProps = {
workspace: PortalWorkspace;
};
export default function PortalClient({ workspace }: PortalClientProps) {
const router = useRouter();
const [me, setMe] = useState<UserProfile | null>(null);
const [overview, setOverview] = useState<PortalOverview | null>(null);
const [items, setItems] = useState<PortalItem[]>([]);
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [selectedItem, setSelectedItem] = useState<PortalItem | null>(null);
const [comments, setComments] = useState<PortalComment[]>([]);
const [activity, setActivity] = useState<PortalActivity[]>([]);
const [loadingItems, setLoadingItems] = useState(true);
const [loadingItem, setLoadingItem] = useState(false);
const [creating, setCreating] = useState(false);
const [saving, setSaving] = useState(false);
const [commenting, setCommenting] = useState(false);
const [respondingResolution, setRespondingResolution] = useState(false);
const [deleteConfirming, setDeleteConfirming] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [totalItems, setTotalItems] = useState(0);
const [hasMore, setHasMore] = useState(false);
const filterKind = workspace;
const [filterStatus, setFilterStatus] = useState("");
const [filterMine, setFilterMine] = useState(false);
const [filterSearch, setFilterSearch] = useState("");
const [createTitle, setCreateTitle] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createMediaType, setCreateMediaType] = useState("");
const [createYear, setCreateYear] = useState("");
const [createExternalRef, setCreateExternalRef] = useState("");
const [createPriority, setCreatePriority] = useState<"low" | "normal" | "high" | "urgent">("normal");
const [editTitle, setEditTitle] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editMediaType, setEditMediaType] = useState("");
const [editYear, setEditYear] = useState("");
const [editExternalRef, setEditExternalRef] = useState("");
const [editStatus, setEditStatus] = useState("new");
const [editIssueType, setEditIssueType] = useState("general");
const [editRequestStatus, setEditRequestStatus] = useState("pending");
const [editMediaStatus, setEditMediaStatus] = useState("pending");
const [editPriority, setEditPriority] = useState("normal");
const [editAssignee, setEditAssignee] = useState("");
const [commentText, setCommentText] = useState("");
const [commentInternal, setCommentInternal] = useState(false);
const [preselectedItemId, setPreselectedItemId] = useState<number | null>(null);
const [discoverQuery, setDiscoverQuery] = useState("");
const [discoverLoading, setDiscoverLoading] = useState(false);
const [discoverResults, setDiscoverResults] = useState<DiscoveryResult[]>([]);
const [discoverError, setDiscoverError] = useState<string | null>(null);
const [requestingTmdbIds, setRequestingTmdbIds] = useState<Record<string, boolean>>({});
const [issueCategory, setIssueCategory] = useState<IssueCategoryId | null>(null);
const [issueMediaTitle, setIssueMediaTitle] = useState("");
const [issueMediaType, setIssueMediaType] = useState<"movie" | "tv">("movie");
const [issueSymptoms, setIssueSymptoms] = useState<string[]>([]);
const [issueDevices, setIssueDevices] = useState<string[]>([]);
const [mediaServerStatus, setMediaServerStatus] = useState<MediaServerStatus | null>(null);
const [mediaServerChecking, setMediaServerChecking] = useState(false);
const [mediaServerError, setMediaServerError] = useState<string | null>(null);
const [issueMediaQuery, setIssueMediaQuery] = useState("");
const [issueMediaSearching, setIssueMediaSearching] = useState(false);
const [issueMediaResults, setIssueMediaResults] = useState<DiscoveryResult[]>([]);
const [issueSelectedMedia, setIssueSelectedMedia] = useState<DiscoveryResult | null>(null);
const [issueOptions, setIssueOptions] = useState<IssueTargetOptions | null>(null);
const [issueOptionsLoading, setIssueOptionsLoading] = useState(false);
const [issueOptionsMessage, setIssueOptionsMessage] = useState<string | null>(null);
const [issueStep, setIssueStep] = useState<IssueStep>("problem");
const issueOptionsVersion = useRef(0);
const [activeSeasonNumber, setActiveSeasonNumber] = useState<number | null>(null);
const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState<number[]>([]);
const [selectedEpisodeIds, setSelectedEpisodeIds] = useState<number[]>([]);
const isAdmin = me?.role === "admin";
const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0);
const workspaceLabel = workspace === "request" ? "request" : "issue";
const workspaceLabelPlural = workspace === "request" ? "requests" : "issues";
const selectedIssueDefinition = ISSUE_CATEGORIES.find((category) => category.id === issueCategory) ?? null;
const issueNeedsServerCheck = issueCategory === "playback" || issueCategory === "service_unavailable";
const issueNeedsDevices = issueNeedsServerCheck || issueCategory === "audio" || issueCategory === "subtitle";
const issueRequiresExistingFile =
issueCategory === "broken_media" ||
issueCategory === "wrong_content" ||
issueCategory === "audio" ||
issueCategory === "subtitle" ||
issueCategory === "playback";
const issueSupportsReplacement =
issueCategory === "broken_media" ||
issueCategory === "wrong_content" ||
issueCategory === "audio" ||
(issueCategory === "playback" && mediaServerStatus?.status !== "down");
const selectedEpisodeOptions = (issueOptions?.episodes ?? []).filter((episode) =>
selectedEpisodeIds.includes(episode.id),
);
const selectedReplacementFileIds = Array.from(
new Set(
selectedEpisodeOptions
.map((episode) => episode.file_id)
.filter((fileId): fileId is number => typeof fileId === "number" && fileId > 0),
),
);
const missingEntireTitle = issueSymptoms.includes("Entire title is missing");
const missingSeasons = issueSymptoms.includes("Season is missing");
const missingEpisodes =
issueSymptoms.includes("Episode is missing") || issueSymptoms.includes("Part or edition is missing");
const selectedMissingEpisodeIds = (issueOptions?.episodes ?? [])
.filter((episode) => {
if (missingEntireTitle) return episode.missing;
if (missingEpisodes && selectedEpisodeIds.includes(episode.id)) return true;
return missingSeasons && episode.missing && selectedSeasonNumbers.includes(episode.season_number);
})
.map((episode) => episode.id);
const movieTargetAvailable = !issueRequiresExistingFile || Boolean(issueOptions?.movie?.has_file);
const issueTargetReady = Boolean(
issueOptions &&
issueSymptoms.length > 0 &&
(issueOptions.request_type === "movie"
? movieTargetAvailable
: issueCategory === "missing_content"
? missingEntireTitle ||
((missingSeasons ? selectedSeasonNumbers.length > 0 : true) &&
(missingEpisodes ? selectedEpisodeIds.length > 0 : true))
: selectedEpisodeIds.length > 0),
);
const issueNeedsTvTargets = issueOptions?.request_type === "tv" && !missingEntireTitle;
const issueSteps: IssueStep[] = [
"problem",
"media",
"symptoms",
...(issueNeedsTvTargets ? ["targets" as const] : []),
...(issueNeedsDevices ? ["devices" as const] : []),
"review",
];
const stepProps = (step: IssueStep) => ({
number: issueSteps.indexOf(step) + 1,
active: issueStep === step,
complete: issueSteps.indexOf(issueStep) > issueSteps.indexOf(step),
onEdit: () => {
if (issueOptionsLoading) {
issueOptionsVersion.current += 1;
setIssueOptionsLoading(false);
}
setIssueStep(step);
},
});
const afterTargets: IssueStep = issueNeedsDevices ? "devices" : "review";
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const raw = params.get("item");
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
setPreselectedItemId(Number.isNaN(parsed) || parsed <= 0 ? null : parsed);
const requestId = Number.parseInt(params.get("reportRequest") ?? "", 10);
const title = params.get("title")?.trim();
const type = params.get("type");
const rawYear = Number.parseInt(params.get("year") ?? "", 10);
if (requestId > 0 && title && (type === "movie" || type === "tv")) {
const media: DiscoveryResult = {
title,
year: rawYear >= 1870 && rawYear <= 2200 ? rawYear : null,
type,
requestId,
statusLabel: "Ready to watch",
accessible: true,
};
setIssueSelectedMedia(media);
setIssueMediaTitle(media.title);
setIssueMediaType(type);
setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ""}`);
setIssueStep("problem");
}
}, []);
const loadMe = async () => {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/auth/me`);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return null;
}
throw new Error(`Failed to load session (${response.status})`);
}
const data = await response.json();
const profile: UserProfile = {
username: data?.username ?? "unknown",
role: data?.role ?? "user",
};
setMe(profile);
return profile;
};
const loadOverview = async () => {
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/portal/overview?kind=${workspace}`);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
throw new Error(`Failed to load portal overview (${response.status})`);
}
const data = await response.json();
setOverview(data);
} catch (err) {
console.error(err);
}
};
const loadItem = async (itemId: number) => {
setLoadingItem(true);
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/portal/items/${itemId}`);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
if (response.status === 404) {
setSelectedItem(null);
setComments([]);
setActivity([]);
return;
}
throw new Error(`Failed to load portal item (${response.status})`);
}
const data = await response.json();
const item = (data?.item ?? null) as PortalItem | null;
setSelectedItem(item);
setComments(Array.isArray(data?.comments) ? data.comments : []);
setActivity(Array.isArray(data?.activity) ? data.activity : []);
} catch (err) {
console.error(err);
setError("Could not load portal item details.");
} finally {
setLoadingItem(false);
}
};
const loadItems = async (options?: { preferItemId?: number | null }) => {
setLoadingItems(true);
try {
const baseUrl = getApiBase();
const params = new URLSearchParams({
limit: "60",
offset: "0",
});
params.set("kind", filterKind);
if (filterStatus) params.set("status", filterStatus);
if (filterMine) params.set("mine", "1");
const trimmedSearch = filterSearch.trim();
if (trimmedSearch) params.set("search", trimmedSearch);
const response = await authFetch(`${baseUrl}/portal/items?${params.toString()}`);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
throw new Error(`Failed to load portal items (${response.status})`);
}
const data = await response.json();
const loadedItems = Array.isArray(data?.items) ? (data.items as PortalItem[]) : [];
setItems(loadedItems);
setTotalItems(Number(data?.total ?? loadedItems.length ?? 0));
setHasMore(Boolean(data?.has_more));
const preferred = options?.preferItemId ?? selectedItemId ?? preselectedItemId;
if (preferred && loadedItems.some((item) => item.id === preferred)) {
setSelectedItemId(preferred);
} else if (loadedItems.length > 0 && workspace === "request") {
setSelectedItemId(loadedItems[0].id);
} else {
setSelectedItemId(null);
setSelectedItem(null);
setComments([]);
}
} catch (err) {
console.error(err);
setError("Could not load portal items.");
} finally {
setLoadingItems(false);
}
};
const resolveTmdbArtworkUrl = (path?: string | null, size: "w185" | "w342" = "w185") => {
if (!path) return null;
const normalized = path.startsWith("/") ? path : `/${path}`;
return `https://image.tmdb.org/t/p/${size}${normalized}`;
};
const runDiscoverySearch = async (event?: React.FormEvent) => {
if (event) event.preventDefault();
const query = discoverQuery.trim();
if (!query) {
setDiscoverResults([]);
setDiscoverError("Enter a title to search.");
return;
}
setDiscoverLoading(true);
setDiscoverError(null);
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(query)}`);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
const text = await response.text();
throw new Error(text || `Search failed (${response.status})`);
}
const data = await response.json();
const mapped: DiscoveryResult[] = Array.isArray(data?.results)
? data.results.map((item: Record<string, unknown>) => ({
title: typeof item.title === "string" ? item.title : "Untitled",
year: typeof item.year === "number" ? item.year : null,
type: item.type === "movie" || item.type === "tv" ? item.type : null,
tmdbId: typeof item.tmdbId === "number" ? item.tmdbId : null,
requestId: typeof item.requestId === "number" ? item.requestId : null,
statusLabel: typeof item.statusLabel === "string" ? item.statusLabel : null,
status: typeof item.status === "number" ? item.status : null,
accessible: Boolean(item.accessible),
posterPath: typeof item.posterPath === "string" ? item.posterPath : null,
backdropPath: typeof item.backdropPath === "string" ? item.backdropPath : null,
}))
: [];
setDiscoverResults(mapped);
} catch (err) {
console.error(err);
setDiscoverResults([]);
setDiscoverError(err instanceof Error ? err.message : "Search failed.");
} finally {
setDiscoverLoading(false);
}
};
const requestDiscoveryItem = async (item: DiscoveryResult) => {
if (!item.tmdbId || !item.type) {
setError("Could not request this result because required media details are missing.");
return;
}
const key = `${item.type}:${item.tmdbId}`;
setRequestingTmdbIds((prev) => ({ ...prev, [key]: true }));
setError(null);
setStatus(null);
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/requests/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mediaType: item.type,
tmdbId: item.tmdbId,
}),
});
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
const text = await response.text();
throw new Error(text || `Request failed (${response.status})`);
}
const data = await response.json();
const requestId = typeof data?.requestId === "number" ? data.requestId : null;
const statusLabel = typeof data?.statusLabel === "string" ? data.statusLabel : item.statusLabel;
const statusCode = typeof data?.statusCode === "number" ? data.statusCode : item.status;
setDiscoverResults((prev) =>
prev.map((entry) =>
entry.tmdbId === item.tmdbId && entry.type === item.type
? {
...entry,
requestId,
statusLabel,
status: statusCode,
accessible: true,
}
: entry,
),
);
if (requestId) {
const mode = data?.status === "exists" ? "already exists" : "created";
setStatus(`Request ${mode}. Open request #${requestId} for the full pipeline.`);
} else {
setStatus("Request submitted.");
}
await Promise.all([loadItems(), loadOverview()]);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not create request.");
} finally {
setRequestingTmdbIds((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
};
const loadIssueOptions = async (media: DiscoveryResult) => {
const version = ++issueOptionsVersion.current;
setIssueOptionsLoading(false);
setIssueOptions(null);
setActiveSeasonNumber(null);
setSelectedSeasonNumbers([]);
setSelectedEpisodeIds([]);
if (!media.requestId) {
setIssueOptionsMessage("This title is not linked to a Magent request yet.");
return;
}
setIssueOptionsLoading(true);
setIssueOptionsMessage(null);
try {
const response = await authFetch(`${getApiBase()}/requests/${media.requestId}/issue-options`);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
const text = await response.text();
throw new Error(text || "Could not load seasons and episodes from Sonarr/Radarr.");
}
const payload = (await response.json()) as IssueTargetOptions;
if (version !== issueOptionsVersion.current) return;
const verifiedMedia: DiscoveryResult = {
...media,
title: payload.title,
type: payload.request_type,
};
setIssueSelectedMedia(verifiedMedia);
setIssueMediaTitle(verifiedMedia.title);
setIssueMediaType(payload.request_type);
setIssueOptions(payload);
setIssueOptionsMessage(payload.message ?? null);
const firstSeason = payload.seasons.find((season) => season.season_number > 0) ?? payload.seasons[0];
setActiveSeasonNumber(firstSeason?.season_number ?? null);
setIssueStep("symptoms");
} catch (err) {
if (version !== issueOptionsVersion.current) return;
console.error(err);
setIssueOptionsMessage(
err instanceof Error ? err.message : "Could not load seasons and episodes from Sonarr/Radarr.",
);
} finally {
if (version === issueOptionsVersion.current) setIssueOptionsLoading(false);
}
};
const searchIssueMedia = async (event?: React.FormEvent) => {
event?.preventDefault();
const query = issueMediaQuery.trim();
if (!query) {
setError("Enter the movie or TV show you are having trouble with.");
return;
}
setIssueMediaSearching(true);
setError(null);
setIssueMediaResults([]);
issueOptionsVersion.current += 1;
setIssueOptionsLoading(false);
setIssueSelectedMedia(null);
setIssueMediaTitle("");
setIssueOptions(null);
setActiveSeasonNumber(null);
setSelectedSeasonNumbers([]);
setSelectedEpisodeIds([]);
try {
const response = await authFetch(`${getApiBase()}/requests/search?query=${encodeURIComponent(query)}`);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
throw new Error("Magent could not search the media catalogue.");
}
const payload = await response.json();
const results: DiscoveryResult[] = Array.isArray(payload?.results)
? payload.results
.filter((item: Record<string, unknown>) => item.type === "movie" || item.type === "tv")
.map((item: Record<string, unknown>) => ({
title: typeof item.title === "string" ? item.title : "Untitled",
year: typeof item.year === "number" ? item.year : null,
type: item.type,
tmdbId: typeof item.tmdbId === "number" ? item.tmdbId : null,
requestId: typeof item.requestId === "number" ? item.requestId : null,
statusLabel: typeof item.statusLabel === "string" ? item.statusLabel : null,
status: typeof item.status === "number" ? item.status : null,
accessible: Boolean(item.accessible),
posterPath: typeof item.posterPath === "string" ? item.posterPath : null,
backdropPath: typeof item.backdropPath === "string" ? item.backdropPath : null,
}))
: [];
setIssueMediaResults(results.slice(0, 12));
if (results.length === 0) {
setError("No matching movie or TV show was found. Try the title without a year.");
}
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Magent could not search the media catalogue.");
} finally {
setIssueMediaSearching(false);
}
};
const selectIssueMedia = (media: DiscoveryResult) => {
setIssueSymptoms([]);
setIssueDevices([]);
setIssueSelectedMedia(media);
setIssueMediaTitle(media.title);
setIssueMediaType(media.type === "tv" ? "tv" : "movie");
setIssueMediaResults([]);
setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ""}`);
setError(null);
void loadIssueOptions(media);
};
const checkMediaServer = async () => {
setMediaServerChecking(true);
setMediaServerError(null);
try {
const response = await authFetch(`${getApiBase()}/portal/issues/media-status`);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
throw new Error("The live media-server check is temporarily unavailable.");
}
const payload = (await response.json()) as MediaServerStatus;
setMediaServerStatus(payload);
} catch (err) {
console.error(err);
setMediaServerStatus(null);
setMediaServerError(
err instanceof Error ? err.message : "The live media-server check is temporarily unavailable.",
);
} finally {
setMediaServerChecking(false);
}
};
const chooseIssueCategory = (category: IssueCategoryId) => {
setIssueCategory(category);
setIssueSymptoms([]);
setIssueDevices([]);
setMediaServerStatus(null);
setMediaServerError(null);
setError(null);
setStatus(null);
const shouldLoadSelectedRequest = Boolean(issueSelectedMedia?.requestId && !issueOptions);
setIssueStep(issueSelectedMedia && issueOptions ? "symptoms" : "media");
setSelectedSeasonNumbers([]);
setSelectedEpisodeIds([]);
if (category === "playback" || category === "service_unavailable") {
void checkMediaServer();
}
if (shouldLoadSelectedRequest && issueSelectedMedia) void loadIssueOptions(issueSelectedMedia);
};
const toggleStringChoice = (value: string, setter: React.Dispatch<React.SetStateAction<string[]>>) => {
setter((current) => (current.includes(value) ? current.filter((item) => item !== value) : [...current, value]));
};
const runIssueAction = async (path: string, body: Record<string, unknown>): Promise<string> => {
const response = await authFetch(`${getApiBase()}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
let detail = "The follow-up action could not be started.";
try {
const payload = await response.json();
if (typeof payload?.detail === "string") detail = payload.detail;
} catch {
// Keep the plain-language fallback.
}
throw new Error(detail);
}
const payload = await response.json();
return typeof payload?.message === "string" ? payload.message : "The follow-up action started.";
};
const createGuidedIssue = async (event: React.FormEvent) => {
event.preventDefault();
if (creating || issueStep !== "review") return;
if (!selectedIssueDefinition || !issueCategory) {
setError("Choose the problem that best matches what you are seeing.");
return;
}
const cleanMediaTitle = issueMediaTitle.trim();
if (!cleanMediaTitle || !issueSelectedMedia?.requestId || !issueOptions) {
setError("Search for and select a tracked movie or TV show first.");
return;
}
if (issueSymptoms.length === 0) {
setError("Choose what needs to be corrected.");
return;
}
const isMovie = issueOptions.request_type === "movie";
if (isMovie && !movieTargetAvailable) {
setError("There is no managed movie file available for this repair. Report it as missing instead.");
return;
}
if (!isMovie && issueCategory === "missing_content" && missingSeasons && selectedSeasonNumbers.length === 0) {
setError("Choose at least one missing season.");
return;
}
if (!isMovie && issueCategory === "missing_content" && missingEpisodes && selectedEpisodeIds.length === 0) {
setError("Choose at least one missing episode.");
return;
}
if (!isMovie && issueCategory !== "missing_content" && selectedEpisodeIds.length === 0) {
setError("Choose at least one affected episode.");
return;
}
const movieFileId = issueOptions.movie?.file_id;
const actionFileIds = isMovie ? (typeof movieFileId === "number" ? [movieFileId] : []) : selectedReplacementFileIds;
if (issueSupportsReplacement && actionFileIds.length === 0) {
setError("Sonarr/Radarr does not report a replaceable file for the selected content.");
return;
}
setCreating(true);
setError(null);
setStatus(null);
try {
const diagnosticLines: string[] = [];
if (mediaServerStatus) {
diagnosticLines.push(
`Media server check: ${mediaServerStatus.headline}`,
`Checked: ${formatDate(mediaServerStatus.checked_at)}`,
);
if (typeof mediaServerStatus.latency_ms === "number") {
diagnosticLines.push(`Response time: ${mediaServerStatus.latency_ms} ms`);
}
if (mediaServerStatus.server?.restart_pending) {
diagnosticLines.push("Server restart pending: yes");
}
if (mediaServerStatus.activity?.available) {
diagnosticLines.push(
`Active streams: ${mediaServerStatus.activity.active_streams ?? 0}`,
`Active transcodes: ${mediaServerStatus.activity.transcoding_streams ?? 0}`,
);
}
} else if (issueNeedsServerCheck) {
diagnosticLines.push("Media server check: unavailable at the time of reporting");
}
const description = [
`Problem: ${selectedIssueDefinition.label}`,
`What needs correction: ${issueSymptoms.join(", ")}`,
cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : null,
cleanMediaTitle ? `Media type: ${issueMediaType === "tv" ? "TV show" : "Movie"}` : null,
issueSelectedMedia?.requestId ? `Magent request: #${issueSelectedMedia.requestId}` : null,
selectedSeasonNumbers.length
? `Seasons: ${selectedSeasonNumbers.map((season) => `Season ${season}`).join(", ")}`
: null,
selectedEpisodeOptions.length
? `Episodes: ${selectedEpisodeOptions.map((episode) => episode.code).join(", ")}`
: null,
issueDevices.length ? `Devices: ${issueDevices.join(", ")}` : null,
...diagnosticLines,
]
.filter((line): line is string => Boolean(line))
.join("\n");
const titleTarget = cleanMediaTitle;
const resolvedIssueType =
issueCategory === "playback" && issueSymptoms.some((symptom) => symptom.toLowerCase().includes("transcode"))
? "transcode"
: selectedIssueDefinition.issueType;
const priority =
mediaServerStatus?.status === "down" || issueCategory === "service_unavailable" ? "high" : "normal";
const response = await authFetch(`${getApiBase()}/portal/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: "issue",
title: `${selectedIssueDefinition.titlePrefix}: ${titleTarget}`,
description,
issue_type: resolvedIssueType,
media_type: cleanMediaTitle ? issueMediaType : null,
external_ref: issueSelectedMedia?.requestId ? `/requests/${issueSelectedMedia.requestId}` : null,
priority,
}),
});
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
const text = await response.text();
throw new Error(text || "Could not submit the issue.");
}
const data = await response.json();
const item = data?.item as PortalItem | undefined;
let completionMessage = item?.id ? `Issue #${item.id} submitted.` : "Issue submitted.";
const requestId = issueSelectedMedia.requestId;
const actionBase = `/requests/${requestId}/actions`;
let actionMessage = "";
if (issueOptions.can_act === false) {
actionMessage = "Support has been given the selected title and affected content.";
} else if (issueCategory === "missing_content") {
actionMessage = await runIssueAction(`${actionBase}/search-missing`, {
issue_id: item?.id ?? null,
episode_ids: selectedMissingEpisodeIds,
season_numbers: selectedSeasonNumbers,
});
} else if (issueCategory === "subtitle") {
actionMessage = await runIssueAction(`${actionBase}/repair-subtitles`, {
issue_id: item?.id ?? null,
episode_ids: isMovie ? [] : selectedEpisodeIds,
forced: issueSymptoms.includes("Forced subtitles are missing"),
});
} else if (issueSupportsReplacement) {
actionMessage = await runIssueAction(`${actionBase}/replace`, {
issue_id: item?.id ?? null,
file_ids: actionFileIds,
confirmed: true,
});
}
if (actionMessage) completionMessage = `${completionMessage} ${actionMessage}`;
setStatus(completionMessage);
setError(null);
setIssueCategory(null);
setIssueMediaTitle("");
setIssueMediaType("movie");
setIssueSymptoms([]);
setIssueDevices([]);
setMediaServerStatus(null);
setIssueMediaQuery("");
setIssueMediaResults([]);
setIssueSelectedMedia(null);
setIssueOptions(null);
setIssueOptionsMessage(null);
setIssueStep("problem");
setActiveSeasonNumber(null);
setSelectedSeasonNumbers([]);
setSelectedEpisodeIds([]);
await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()]);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not submit the issue.");
} finally {
setCreating(false);
}
};
// biome-ignore lint/correctness/useExhaustiveDependencies: Bootstrap loaders are deliberately coordinated once per route mount.
useEffect(() => {
if (!getToken()) {
router.push("/login");
return;
}
const bootstrap = async () => {
try {
setError(null);
await loadMe();
await Promise.all([loadOverview(), loadItems({ preferItemId: preselectedItemId })]);
} catch (err) {
console.error(err);
setError("Could not load request portal.");
}
};
void bootstrap();
}, [router]);
// biome-ignore lint/correctness/useExhaustiveDependencies: Filter values intentionally trigger the loader without recreating it.
useEffect(() => {
if (!getToken()) {
return;
}
void loadItems({ preferItemId: preselectedItemId });
}, [filterStatus, filterMine, filterSearch, workspace]);
useEffect(() => {
void workspace;
setFilterStatus("");
setCreateMediaType("");
setCreateYear("");
setSelectedItemId(null);
setSelectedItem(null);
setComments([]);
setActivity([]);
}, [workspace]);
// biome-ignore lint/correctness/useExhaustiveDependencies: Selection is the lifecycle key for this request.
useEffect(() => {
if (selectedItemId == null) return;
void loadItem(selectedItemId);
}, [selectedItemId]);
useEffect(() => {
if (!selectedItem) return;
setEditTitle(selectedItem.title ?? "");
setEditDescription(selectedItem.description ?? "");
setEditMediaType(selectedItem.media_type ?? "");
setEditYear(selectedItem.year == null ? "" : String(selectedItem.year));
setEditExternalRef(selectedItem.external_ref ?? "");
setEditStatus(selectedItem.status ?? "new");
setEditIssueType(selectedItem.issue?.issue_type ?? "general");
setEditRequestStatus(selectedItem.workflow?.request_status ?? "pending");
setEditMediaStatus(selectedItem.workflow?.media_status ?? "pending");
setEditPriority(selectedItem.priority ?? "normal");
setEditAssignee(selectedItem.assignee_username ?? "");
}, [selectedItem]);
const createItem = async (event: React.FormEvent) => {
event.preventDefault();
setCreating(true);
setError(null);
setStatus(null);
try {
const payload: Record<string, unknown> = {
kind: workspace,
title: createTitle,
description: createDescription,
media_type: workspace === "request" ? createMediaType || null : null,
year: workspace === "request" && createYear.trim() ? toPositiveInt(createYear) : null,
external_ref: createExternalRef || null,
priority: createPriority,
};
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/portal/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
const text = await response.text();
throw new Error(text || "Could not create portal item.");
}
const data = await response.json();
const item = data?.item as PortalItem | undefined;
setStatus(workspace === "request" ? "Request item created." : "Issue item created.");
setCreateTitle("");
setCreateDescription("");
setCreateMediaType("");
setCreateYear("");
setCreateExternalRef("");
setCreatePriority("normal");
await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()]);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not create portal item.");
} finally {
setCreating(false);
}
};
const saveItem = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedItem) return;
setSaving(true);
setError(null);
setStatus(null);
try {
const payload: Record<string, unknown> = {
title: editTitle,
description: editDescription,
media_type: editMediaType || null,
year: editYear.trim() ? toPositiveInt(editYear) : null,
external_ref: editExternalRef || null,
};
if (selectedItem.permissions?.can_moderate) {
if (selectedItem.kind === "request") {
payload.request_status = editRequestStatus;
payload.media_status = editMediaStatus;
} else {
payload.status = editStatus;
payload.issue_type = editIssueType;
}
payload.priority = editPriority;
payload.assignee_username = editAssignee || null;
}
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/portal/items/${selectedItem.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
const text = await response.text();
throw new Error(text || "Could not update portal item.");
}
const data = await response.json();
setSelectedItem((data?.item ?? null) as PortalItem | null);
setComments(Array.isArray(data?.comments) ? data.comments : []);
setActivity(Array.isArray(data?.activity) ? data.activity : []);
setStatus("Portal item updated.");
await Promise.all([loadItems({ preferItemId: selectedItem.id }), loadOverview()]);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not update portal item.");
} finally {
setSaving(false);
}
};
const postComment = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedItem) return;
if (!commentText.trim()) {
setError("Comment message is required.");
return;
}
setCommenting(true);
setError(null);
setStatus(null);
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/portal/items/${selectedItem.id}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: commentText,
is_internal: commentInternal,
}),
});
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
const text = await response.text();
throw new Error(text || "Could not add comment.");
}
setCommentText("");
setCommentInternal(false);
setStatus("Comment added.");
await Promise.all([loadItem(selectedItem.id), loadItems({ preferItemId: selectedItem.id }), loadOverview()]);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not add comment.");
} finally {
setCommenting(false);
}
};
const respondToResolution = async (resolved: boolean) => {
if (!selectedItem) return;
setRespondingResolution(true);
setError(null);
setStatus(null);
try {
const response = await authFetch(`${getApiBase()}/portal/issues/${selectedItem.id}/resolution-response`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resolved }),
});
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
const text = await response.text();
throw new Error(text || "Could not record your confirmation.");
}
const data = await response.json();
setSelectedItem((data?.item ?? null) as PortalItem | null);
setComments(Array.isArray(data?.comments) ? data.comments : []);
setActivity(Array.isArray(data?.activity) ? data.activity : []);
setStatus(
resolved ? "Thanks. This issue has been closed." : "Thanks. The issue is back in progress for another look.",
);
await Promise.all([loadItems({ preferItemId: selectedItem.id }), loadOverview()]);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not record your confirmation.");
} finally {
setRespondingResolution(false);
}
};
const deleteIssue = async () => {
if (selectedItem?.kind !== "issue" || !selectedItem.permissions?.can_delete) return;
setDeleting(true);
setError(null);
setStatus(null);
const issueId = selectedItem.id;
try {
const response = await authFetch(`${getApiBase()}/portal/items/${issueId}`, {
method: "DELETE",
});
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
const payload = await response.json().catch(() => null);
throw new Error(payload?.detail || `Could not delete issue (${response.status})`);
}
closeIssueModal();
setStatus(`Issue #${issueId} was deleted. The linked media request was not changed.`);
await Promise.all([loadItems(), loadOverview()]);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not delete the issue.");
} finally {
setDeleting(false);
}
};
const closeIssueModal = () => {
setSelectedItemId(null);
setSelectedItem(null);
setComments([]);
setActivity([]);
setCommentText("");
setDeleteConfirming(false);
};
useEffect(() => {
void selectedItemId;
setDeleteConfirming(false);
}, [selectedItemId]);
// biome-ignore lint/correctness/useExhaustiveDependencies: Modal cleanup is keyed by workspace and selected item.
useEffect(() => {
if (workspace !== "issue" || selectedItemId == null) return;
const previousOverflow = document.body.style.overflow;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") closeIssueModal();
};
document.body.style.overflow = "hidden";
window.addEventListener("keydown", closeOnEscape);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", closeOnEscape);
};
}, [workspace, selectedItemId]);
if (loadingItems && !items.length) {
return <main className="card">Loading {workspace === "issue" ? "issues" : "requests"}...</main>;
}
return (
<main className={`card portal-page ${workspace === "issue" ? "issue-portal-page" : ""}`}>
<PageHeading
title={workspace === "request" ? "Request portal" : "Issues"}
description={
workspace === "request"
? "Search and track your content requests."
: "Tell us what is wrong. Well guide you through the fix."
}
actions={
workspace === "issue" ? (
<span className="page-heading-meta">
{visibleKindCount} reported {visibleKindCount === 1 ? "issue" : "issues"}
</span>
) : undefined
}
/>
{workspace === "issue" &&
items
.filter(
(item) =>
item.status === "awaiting_confirmation" &&
item.permissions?.can_confirm_resolution &&
item.created_by_username === me?.username,
)
.map((item) => (
<section key={item.id} className="resolution-choice">
<h2>Is it fixed?</h2>
<p>{item.title}</p>
<a className="button" href={`/issues/confirm/${item.id}`}>
Answer YES or NO
</a>
</section>
))}
{workspace === "request" ? (
<section className="portal-workspace-switch">
<button type="button" className="is-active" disabled>
New requests
</button>
<button type="button" onClick={() => router.push("/portal/issues")}>
Issues
</button>
</section>
) : null}
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
{workspace === "request" ? (
<section className="admin-panel portal-discovery-panel">
<div className="user-directory-panel-header">
<div>
<h2>Search and request content</h2>
<p className="lede">Search Seerr content directly, then submit a request in one click.</p>
</div>
</div>
<form className="portal-discovery-form" onSubmit={runDiscoverySearch}>
<input
value={discoverQuery}
onChange={(event) => setDiscoverQuery(event.target.value)}
placeholder="Search movies or TV shows"
/>
<button type="submit" disabled={discoverLoading}>
{discoverLoading ? "Searching…" : "Search"}
</button>
</form>
{discoverError && <div className="error-banner">{discoverError}</div>}
<div className="portal-discovery-results">
{discoverLoading ? (
<div className="status-banner">Searching Seerr</div>
) : discoverResults.length === 0 ? (
<div className="status-banner">No discovery results yet.</div>
) : (
discoverResults.map((item, index) => {
const key = `${item.type ?? "unknown"}:${item.tmdbId ?? index}`;
const requesting = Boolean(requestingTmdbIds[key]);
const poster = resolveTmdbArtworkUrl(item.posterPath, "w185");
const hasRequest = typeof item.requestId === "number" && item.requestId > 0;
return (
<div key={key} className="portal-discovery-item">
<div className="portal-discovery-media">
{poster ? (
<img src={poster} alt="" loading="lazy" />
) : (
<div className="poster-fallback">No artwork</div>
)}
</div>
<div className="portal-discovery-main">
<div className="portal-discovery-title-row">
<strong>{item.title || "Untitled"}</strong>
<span className="small-pill">{item.type ?? "unknown"}</span>
{item.year ? <span className="small-pill is-muted">{item.year}</span> : null}
</div>
<p>
{hasRequest ? (
<>
Already requested
{item.statusLabel ? ` · ${item.statusLabel}` : ""}
{item.requestId ? ` · #${item.requestId}` : ""}
</>
) : (
"Not requested yet"
)}
</p>
</div>
<div className="portal-discovery-actions">
{hasRequest ? (
<button type="button" onClick={() => router.push(`/requests/${item.requestId}`)}>
Open request
</button>
) : (
<button
type="button"
onClick={() => void requestDiscoveryItem(item)}
disabled={requesting || !item.tmdbId || !item.type}
>
{requesting ? "Requesting…" : "Request"}
</button>
)}
</div>
</div>
);
})
)}
</div>
</section>
) : (
<section className="issue-flow issue-flow-progressive">
<form className="issue-guided-form" onSubmit={createGuidedIssue}>
<fieldset className="issue-wizard-fields" disabled={creating}>
<IssueFlowStep
{...stepProps("problem")}
title="What is wrong?"
summary={selectedIssueDefinition?.label ?? ""}
>
{issueSelectedMedia?.requestId ? (
<div className="issue-prefilled-request">
<div>
<span className="section-kicker">Reporting a problem with</span>
<strong>
{issueSelectedMedia.title}
{issueSelectedMedia.year ? ` (${issueSelectedMedia.year})` : ""}
</strong>
<small>Request #{issueSelectedMedia.requestId} is already selected.</small>
</div>
<a className="ghost-button" href={`/requests/${issueSelectedMedia.requestId}`}>
Back to request
</a>
</div>
) : null}
<div className="issue-category-grid">
{ISSUE_CATEGORIES.map((category) => (
<button
key={category.id}
type="button"
className={`issue-category-card ${issueCategory === category.id ? "is-selected" : ""}`}
onClick={() => chooseIssueCategory(category.id)}
>
<span className="issue-category-marker">{category.marker}</span>
<strong>{category.label}</strong>
<p>{category.description}</p>
</button>
))}
</div>
</IssueFlowStep>
{selectedIssueDefinition && issueCategory ? (
<>
<IssueFlowStep
{...stepProps("media")}
title="Which title is affected?"
summary={`${issueSelectedMedia?.title ?? ""}${issueSelectedMedia?.year ? ` (${issueSelectedMedia.year})` : ""}`}
>
<div className="issue-media-finder">
<label>
<span>Find the exact movie or TV show</span>
<div className="issue-media-search-row">
<input
value={issueMediaQuery}
onChange={(event) => {
setIssueMediaQuery(event.target.value);
if (issueSelectedMedia) {
setIssueSelectedMedia(null);
setIssueMediaTitle("");
setIssueOptions(null);
issueOptionsVersion.current += 1;
setIssueOptionsLoading(false);
setIssueSymptoms([]);
setIssueDevices([]);
setActiveSeasonNumber(null);
setSelectedSeasonNumbers([]);
setSelectedEpisodeIds([]);
}
}}
placeholder="Search the Grizzlyflix catalogue"
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void searchIssueMedia();
}
}}
/>
<button type="button" onClick={() => void searchIssueMedia()} disabled={issueMediaSearching}>
{issueMediaSearching ? "Searching..." : "Search"}
</button>
</div>
</label>
{issueMediaResults.length > 0 ? (
<div className="issue-media-results">
{issueMediaResults.map((media, index) => {
const poster = resolveTmdbArtworkUrl(media.posterPath, "w185");
return (
<button
key={`${media.type}:${media.tmdbId ?? index}`}
type="button"
className="issue-media-result"
onClick={() => selectIssueMedia(media)}
>
<span className="issue-media-poster">
{poster ? <img src={poster} alt="" loading="lazy" /> : <i>No artwork</i>}
</span>
<span>
<small>
{media.type === "tv" ? "TV show" : "Movie"}
{media.year ? ` · ${media.year}` : ""}
</small>
<strong>{media.title}</strong>
<b>
{media.requestId
? media.statusLabel || `Request #${media.requestId}`
: "Not currently requested"}
</b>
</span>
</button>
);
})}
</div>
) : null}
{issueSelectedMedia ? (
<div className="issue-selected-media">
<div>
<span className="section-kicker">Selected title</span>
<strong>
{issueSelectedMedia.title}
{issueSelectedMedia.year ? ` (${issueSelectedMedia.year})` : ""}
</strong>
<small>
{issueSelectedMedia.type === "tv" ? "TV show" : "Movie"}
{issueSelectedMedia.requestId
? ` · Magent request #${issueSelectedMedia.requestId} · ${issueSelectedMedia.statusLabel ?? "Tracked"}`
: " · No existing Magent request"}
</small>
</div>
{issueSelectedMedia.requestId ? (
<button
type="button"
className="ghost-button"
onClick={() => router.push(`/requests/${issueSelectedMedia.requestId}`)}
>
Open request
</button>
) : null}
</div>
) : null}
{issueOptionsLoading ? (
<div className="issue-live-scan">
<i />
<span>
{issueSelectedMedia?.type === "movie"
? "Checking the movie file"
: "Reading seasons and episodes"}
</span>
</div>
) : null}
{issueOptionsMessage && !issueOptions ? (
<div className="status-banner">{issueOptionsMessage}</div>
) : null}
</div>
{issueSelectedMedia && !issueOptionsLoading ? (
<div className="issue-procedure-actions">
{issueOptions ? (
<button type="button" onClick={() => setIssueStep("symptoms")}>
Continue with this title
</button>
) : issueSelectedMedia.requestId ? (
<button type="button" onClick={() => void loadIssueOptions(issueSelectedMedia)}>
Try again
</button>
) : null}
</div>
) : null}
</IssueFlowStep>
{issueOptions ? (
<>
<IssueFlowStep
{...stepProps("symptoms")}
title="What needs to be corrected?"
summary={issueSymptoms.join(", ")}
>
<fieldset className="issue-choice-field">
<legend>Choose all that apply</legend>
<div className="issue-choice-grid">
{(issueOptions.request_type === "movie" && issueCategory === "missing_content"
? ["Entire title is missing"]
: ISSUE_SYMPTOMS[issueCategory]
).map((symptom) => {
const selected = issueSymptoms.includes(symptom);
return (
<button
key={symptom}
type="button"
aria-pressed={selected}
className={selected ? "is-selected" : ""}
onClick={() => {
if (symptom === "Entire title is missing") {
setIssueSymptoms(selected ? [] : [symptom]);
setSelectedSeasonNumbers([]);
setSelectedEpisodeIds([]);
} else {
const withoutEntireTitle = issueSymptoms.filter(
(item) => item !== "Entire title is missing",
);
const nextSymptoms = selected
? withoutEntireTitle.filter((item) => item !== symptom)
: [...withoutEntireTitle, symptom];
setIssueSymptoms(nextSymptoms);
if (symptom === "Season is missing" && selected) {
setSelectedSeasonNumbers([]);
}
if (
(symptom === "Episode is missing" ||
symptom === "Part or edition is missing") &&
selected &&
!nextSymptoms.includes("Episode is missing") &&
!nextSymptoms.includes("Part or edition is missing")
) {
setSelectedEpisodeIds([]);
}
}
}}
>
<span aria-hidden="true">{selected ? "✓" : "+"}</span>
<strong>
{issueOptions.request_type === "movie" && symptom === "Entire title is missing"
? "Movie is missing"
: symptom}
</strong>
</button>
);
})}
</div>
</fieldset>
{issueOptions.request_type === "movie" && !movieTargetAvailable ? (
<div className="status-banner">
No managed movie file is available to repair.
<button
type="button"
className="ghost-button"
onClick={() => chooseIssueCategory("missing_content")}
>
Report missing movie instead
</button>
</div>
) : null}
<div className="issue-procedure-actions">
<button
type="button"
disabled={
!issueSymptoms.length || (issueOptions.request_type === "movie" && !movieTargetAvailable)
}
onClick={() => setIssueStep(issueNeedsTvTargets ? "targets" : afterTargets)}
>
Continue
</button>
</div>
</IssueFlowStep>
{issueNeedsTvTargets ? (
<IssueFlowStep
{...stepProps("targets")}
title="Which seasons or episodes?"
summary={[
...selectedSeasonNumbers.map((season) => `Season ${season}`),
...selectedEpisodeOptions.map((episode) => episode.code),
].join(", ")}
>
{issueSymptoms.length > 0 && issueOptions.request_type === "tv" && !missingEntireTitle ? (
<div className="issue-tv-targets">
<div className="issue-target-heading">
<strong>{missingSeasons ? "Choose the missing seasons" : "Choose a season"}</strong>
<small>You can select more than one.</small>
</div>
{missingSeasons ? (
<div className="issue-season-grid">
{issueOptions.seasons.map((season) => {
const selected = selectedSeasonNumbers.includes(season.season_number);
return (
<button
key={season.season_number}
type="button"
aria-pressed={selected}
className={selected ? "is-selected" : ""}
onClick={() =>
setSelectedSeasonNumbers((current) =>
current.includes(season.season_number)
? current.filter((value) => value !== season.season_number)
: [...current, season.season_number],
)
}
>
<strong>{season.label}</strong>
<small>
{season.missing_count} missing · {season.available_count} available
</small>
{season.best_fit ? <b>This is the best fit</b> : null}
</button>
);
})}
</div>
) : null}
{issueCategory !== "missing_content" || missingEpisodes ? (
<div className="issue-season-grid issue-season-tabs">
{issueOptions.seasons.map((season) => (
<button
key={season.season_number}
type="button"
aria-pressed={activeSeasonNumber === season.season_number}
className={activeSeasonNumber === season.season_number ? "is-selected" : ""}
onClick={() => setActiveSeasonNumber(season.season_number)}
>
<strong>{season.label}</strong>
<small>{season.episode_count} episodes</small>
</button>
))}
</div>
) : null}
{(issueCategory !== "missing_content" || missingEpisodes) &&
activeSeasonNumber !== null ? (
<div className="issue-episode-grid">
{issueOptions.episodes
.filter(
(episode) => episode.season_number === activeSeasonNumber && episode.released,
)
.map((episode) => {
const selected = selectedEpisodeIds.includes(episode.id);
const disabled = issueRequiresExistingFile && !episode.has_file;
return (
<button
key={episode.id}
type="button"
disabled={disabled}
aria-pressed={selected}
className={selected ? "is-selected" : ""}
onClick={() =>
setSelectedEpisodeIds((current) =>
current.includes(episode.id)
? current.filter((value) => value !== episode.id)
: [...current, episode.id],
)
}
>
<span>{selected ? "✓" : episode.code}</span>
<strong>{episode.title}</strong>
<small>
{episode.missing
? "Missing in Sonarr"
: episode.has_file
? "Ready to select"
: "No file in Sonarr"}
</small>
{episode.best_fit ? <b>This is the best fit</b> : null}
</button>
);
})}
</div>
) : null}
</div>
) : null}
<div className="issue-procedure-actions">
<span className="issue-selection-count" role="status">
{selectedSeasonNumbers.length ? `${selectedSeasonNumbers.length} season(s) selected` : ""}
{selectedSeasonNumbers.length && selectedEpisodeIds.length ? " · " : ""}
{selectedEpisodeIds.length ? `${selectedEpisodeIds.length} episode(s) selected` : ""}
</span>
<button
type="button"
disabled={!issueTargetReady}
onClick={() => setIssueStep(afterTargets)}
>
Continue
</button>
</div>
</IssueFlowStep>
) : null}
{issueNeedsDevices ? (
<IssueFlowStep
{...stepProps("devices")}
title="Where did it happen?"
summary={issueDevices.length ? issueDevices.join(", ") : "Not specified"}
>
{issueTargetReady &&
(issueNeedsServerCheck || issueCategory === "audio" || issueCategory === "subtitle") ? (
<fieldset className="issue-choice-field">
<legend>Choose all that apply</legend>
<div className="issue-choice-row">
{DEVICE_OPTIONS.map((device) => (
<button
key={device}
type="button"
aria-pressed={issueDevices.includes(device)}
className={issueDevices.includes(device) ? "is-selected" : ""}
onClick={() => toggleStringChoice(device, setIssueDevices)}
>
<span className="issue-device-check" aria-hidden="true">
{issueDevices.includes(device) ? "✓" : "+"}
</span>
{device}
</button>
))}
</div>
<p className="issue-device-feedback" role="status">
{issueDevices.length
? `Selected: ${issueDevices.join(", ")}`
: "Optional — these choices will be included in your report."}
</p>
</fieldset>
) : null}
<div className="issue-procedure-actions">
<button
type="button"
className="ghost-button"
onClick={() => {
setIssueDevices([]);
setIssueStep("review");
}}
>
Skip
</button>
<button
type="button"
disabled={!issueDevices.length}
onClick={() => setIssueStep("review")}
>
Review issue
</button>
</div>
</IssueFlowStep>
) : null}
<IssueFlowStep {...stepProps("review")} title="Review and submit" summary="">
{issueNeedsServerCheck && issueTargetReady ? (
<section
className={`media-status-check media-status-${mediaServerStatus?.status ?? "checking"}`}
>
<div className="media-status-heading">
<div>
<span className="section-kicker">Live media-server check</span>
<h3>
{mediaServerChecking
? "Checking Jellyfin now..."
: (mediaServerStatus?.headline ?? "Server check unavailable")}
</h3>
</div>
<button
type="button"
className="ghost-button"
onClick={() => void checkMediaServer()}
disabled={mediaServerChecking}
>
{mediaServerChecking ? "Checking..." : "Check again"}
</button>
</div>
{mediaServerChecking ? (
<div className="issue-live-scan">
<i />
<span>Contacting the media server</span>
</div>
) : null}
{mediaServerError ? <div className="error-banner">{mediaServerError}</div> : null}
{mediaServerStatus ? (
<>
<p>{mediaServerStatus.message}</p>
<div className="media-status-metrics">
<div>
<span>Server API</span>
<strong>
{mediaServerStatus.status === "down" ? "Unavailable" : "Responding"}
</strong>
</div>
<div>
<span>Response</span>
<strong>{mediaServerStatus.latency_ms ?? "--"} ms</strong>
</div>
<div>
<span>Active streams</span>
<strong>
{mediaServerStatus.activity?.available
? (mediaServerStatus.activity.active_streams ?? 0)
: "--"}
</strong>
</div>
<div>
<span>Transcoding</span>
<strong>
{mediaServerStatus.activity?.available
? (mediaServerStatus.activity.transcoding_streams ?? 0)
: "--"}
</strong>
</div>
</div>
</>
) : null}
</section>
) : null}
{issueTargetReady ? (
<section className="issue-resolution-card">
<div>
<span className="section-kicker">What will happen</span>
<h3>
{issueOptions?.can_act === false
? "The selected details will be sent to support."
: selectedIssueDefinition.outcome}
</h3>
<p>
{issueOptions?.can_act === false
? "The issue and every selection will be sent to support. Automatic fixes are not enabled for this account."
: issueCategory === "subtitle"
? "The issue will be logged, then Bazarr will search for fresh subtitles for every selection."
: issueCategory === "missing_content"
? "The issue will be logged, then the selected movie, seasons, or episodes will be sent back to the collection pipeline."
: issueSupportsReplacement
? "The issue will be logged, then the selected content will be sent to Sonarr or Radarr for replacement."
: "The live Jellyfin check will be attached so support can see the server state immediately."}
</p>
</div>
<button type="submit" disabled={creating || mediaServerChecking}>
{creating
? "Working..."
: issueOptions?.can_act === false ||
(!issueSupportsReplacement &&
issueCategory !== "missing_content" &&
issueCategory !== "subtitle")
? "Submit issue"
: "Submit and start fix"}
</button>
</section>
) : null}
</IssueFlowStep>
</>
) : null}
</>
) : null}
</fieldset>
</form>
</section>
)}
{workspace === "request" ? (
<>
<section className="portal-overview-grid">
<div className="portal-overview-card">
<span>Total {workspace === "request" ? "requests" : "issues"}</span>
<strong>{visibleKindCount}</strong>
</div>
<div className="portal-overview-card">
<span>Total comments</span>
<strong>{Number(overview?.overview?.total_comments ?? 0)}</strong>
</div>
<div className="portal-overview-card">
<span>My items</span>
<strong>{Number(overview?.my_items ?? 0)}</strong>
</div>
<div className="portal-overview-card">
<span>Visible</span>
<strong>{items.length}</strong>
</div>
</section>
<section className="admin-panel portal-create-panel">
<h2>{workspace === "request" ? "Create request item" : "Create issue item"}</h2>
<p className="lede">
{workspace === "request"
? "Create and track request-related notes in a dedicated request workflow."
: "Create and track operational issues in a dedicated issue workflow."}
</p>
<form onSubmit={createItem} className="admin-form compact-form portal-form-grid">
<label>
<span>Priority</span>
<select
value={createPriority}
onChange={(event) => setCreatePriority(event.target.value as "low" | "normal" | "high" | "urgent")}
>
{PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="portal-field-span-2">
<span>Title</span>
<input
required
value={createTitle}
onChange={(event) => setCreateTitle(event.target.value)}
placeholder={
workspace === "request" ? "Short summary of the request item" : "Short summary of the issue"
}
/>
</label>
<label className="portal-field-span-2">
<span>Description</span>
<textarea
required
rows={4}
value={createDescription}
onChange={(event) => setCreateDescription(event.target.value)}
placeholder={
workspace === "request"
? "Add request context, expected media, and notes."
: "Describe the issue, impact, and expected behavior."
}
/>
</label>
{workspace === "request" && (
<>
<label>
<span>Media type</span>
<select value={createMediaType} onChange={(event) => setCreateMediaType(event.target.value)}>
{MEDIA_TYPE_OPTIONS.map((option) => (
<option key={option.value || "none"} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
<span>Year</span>
<input
value={createYear}
onChange={(event) => setCreateYear(event.target.value)}
inputMode="numeric"
placeholder="Optional"
/>
</label>
</>
)}
<label className="portal-field-span-2">
<span>External reference</span>
<input
value={createExternalRef}
onChange={(event) => setCreateExternalRef(event.target.value)}
placeholder="Optional: URL, ticket number, or request id"
/>
</label>
<div className="admin-inline-actions portal-field-span-2">
<button type="submit" disabled={creating}>
{creating ? "Creating…" : workspace === "request" ? "Create request item" : "Create issue item"}
</button>
</div>
</form>
</section>
</>
) : null}
<div
className={
workspace === "issue"
? `issue-reports-column ${selectedItemId != null ? "has-open-modal" : ""}`
: "portal-lower-content"
}
>
{workspace === "issue" ? (
<div className="issue-history-heading">
<div>
<span className="section-kicker">Issue history</span>
<h2>Reported problems</h2>
</div>
<span>{totalItems} total</span>
</div>
) : null}
<section className="portal-toolbar">
<label>
<span>Status</span>
<select value={filterStatus} onChange={(event) => setFilterStatus(event.target.value)}>
<option value="">All</option>
{(workspace === "request" ? REQUEST_FILTER_STATUS_OPTIONS : ISSUE_FILTER_STATUS_OPTIONS).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
{workspace === "request" ? (
<label className="portal-search-filter">
<span>Search</span>
<input
value={filterSearch}
onChange={(event) => setFilterSearch(event.target.value)}
placeholder="Search request items by title, description, or id"
/>
</label>
) : null}
<label className="inline-checkbox portal-mine-toggle">
<input type="checkbox" checked={filterMine} onChange={(event) => setFilterMine(event.target.checked)} />
My items only
</label>
</section>
<div className="portal-workspace">
<section className="admin-panel portal-list-panel">
<div className="user-directory-panel-header">
<div>
<h2>{workspace === "request" ? "Requests" : "Issues"}</h2>
<p className="lede">
{totalItems} total {workspaceLabelPlural}
{hasMore ? " (showing first 60)" : ""}
</p>
</div>
</div>
{items.length === 0 ? (
<div className="status-banner">No {workspaceLabelPlural} match this filter.</div>
) : (
<div className="portal-item-list">
{items.map((item) => (
<button
key={item.id}
type="button"
className={`portal-item-row ${selectedItemId === item.id ? "is-active" : ""}`}
onClick={() => setSelectedItemId(item.id)}
>
<div className="portal-item-row-main">
<div className="portal-item-row-title">
<strong>{item.title}</strong>
<span className="small-pill">
{item.kind === "issue"
? (ISSUE_TYPE_LABELS[item.issue?.issue_type ?? "general"] ?? "Issue")
: item.kind}
</span>
<span className="small-pill is-muted">{item.priority}</span>
</div>
<p>{item.description}</p>
{item.kind === "issue" ? <IssuePipeline item={item} compact /> : null}
<div className="portal-item-row-meta">
<span>#{item.id}</span>
{item.kind === "request" ? (
<span>Status: {item.workflow?.stage_label ?? item.status}</span>
) : null}
{isAdmin ? <span>By: {item.created_by_username}</span> : null}
<span>Updated: {formatDate(item.last_activity_at)}</span>
</div>
</div>
</button>
))}
</div>
)}
</section>
{workspace === "issue" && selectedItemId != null ? (
<button
type="button"
className="issue-modal-backdrop"
aria-label="Close issue details"
onClick={closeIssueModal}
/>
) : null}
<section
className={`admin-panel portal-detail-panel ${workspace === "issue" ? `issue-detail-modal ${selectedItemId != null ? "is-open" : ""}` : ""}`}
role={workspace === "issue" ? "dialog" : "region"}
aria-labelledby={workspace === "issue" ? "issue-detail-title" : undefined}
>
{workspace === "issue" && selectedItemId != null ? (
<div className="issue-modal-toolbar">
<div>
<span className="section-kicker">Reported problem</span>
<strong id="issue-detail-title">
{selectedItem ? `Issue #${selectedItem.id}` : "Issue details"}
</strong>
</div>
<div className="issue-modal-toolbar-actions">
{selectedItem?.permissions?.can_delete ? (
<button
type="button"
className="danger-button"
disabled={deleting}
onClick={() => setDeleteConfirming(true)}
>
Delete issue
</button>
) : null}
<button type="button" className="ghost-button" disabled={deleting} onClick={closeIssueModal}>
Close
</button>
</div>
</div>
) : null}
{!selectedItemId ? (
<div className="status-banner">Select a {workspaceLabel} to view details.</div>
) : loadingItem ? (
<div className="status-banner">Loading details</div>
) : !selectedItem ? (
<div className="status-banner">{workspace === "request" ? "Request" : "Issue"} not found.</div>
) : (
<>
{selectedItem.kind === "issue" &&
selectedItem.status === "awaiting_confirmation" &&
selectedItem.permissions?.can_confirm_resolution && (
<ResolutionChoice
title={selectedItem.title}
busy={respondingResolution}
onAnswer={(value) => void respondToResolution(value)}
/>
)}
<div className="user-directory-panel-header">
<div>
<h2>
{selectedItem.kind === "request" ? "Request" : "Issue"} #{selectedItem.id}
</h2>
<p className="lede">
{isAdmin ? `Created by ${selectedItem.created_by_username} on ` : "Reported on "}
{formatDate(selectedItem.created_at)}
</p>
{selectedItem.kind === "issue" ? (
<p className="lede">
Category:{" "}
<strong>{ISSUE_TYPE_LABELS[selectedItem.issue?.issue_type ?? "general"] ?? "General"}</strong>
{selectedItem.issue?.is_resolved ? " · Resolved" : ""}
</p>
) : null}
{selectedItem.kind === "request" && (
<p className="lede">
Pipeline:{" "}
<strong>
{selectedItem.workflow?.request_status ?? "pending"} /{" "}
{selectedItem.workflow?.media_status ?? "pending"}
</strong>{" "}
({selectedItem.workflow?.stage_label ?? "Pending"})
</p>
)}
</div>
</div>
{selectedItem.kind === "issue" && deleteConfirming ? (
<section className="issue-delete-confirmation" aria-live="polite">
<div>
<span className="section-kicker">Permanent deletion</span>
<h3>Delete issue #{selectedItem.id}?</h3>
<p>
This removes the issue, its comments, and its activity history. The linked media request and
collected content will not be changed.
</p>
</div>
<div>
<button
type="button"
className="ghost-button"
disabled={deleting}
onClick={() => setDeleteConfirming(false)}
>
Cancel
</button>
<button
type="button"
className="danger-button"
disabled={deleting}
onClick={() => void deleteIssue()}
>
{deleting ? "Deleting…" : "Delete permanently"}
</button>
</div>
</section>
) : null}
{selectedItem.kind === "issue" ? <IssuePipeline item={selectedItem} /> : null}
{selectedItem.kind === "issue" && selectedItem.external_ref?.startsWith("/requests/") ? (
<div className="issue-linked-request">
<div>
<span className="section-kicker">Linked collection record</span>
<strong>{selectedItem.external_ref.replace("/requests/", "Request #")}</strong>
</div>
<button type="button" onClick={() => router.push(selectedItem.external_ref as string)}>
Open request
</button>
</div>
) : null}
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
<label className="portal-field-span-2">
<span>Title</span>
<input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
/>
</label>
<label className="portal-field-span-2">
<span>Description</span>
<textarea
rows={4}
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
/>
</label>
{selectedItem.kind === "request" ? (
<>
<label>
<span>Media type</span>
<select
value={editMediaType}
onChange={(event) => setEditMediaType(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
>
{MEDIA_TYPE_OPTIONS.map((option) => (
<option key={option.value || "none"} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
<span>Year</span>
<input
value={editYear}
onChange={(event) => setEditYear(event.target.value)}
inputMode="numeric"
disabled={!selectedItem.permissions?.can_edit}
/>
</label>
</>
) : null}
<label className="portal-field-span-2">
<span>External reference</span>
<input
value={editExternalRef}
onChange={(event) => setEditExternalRef(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
/>
</label>
{selectedItem.permissions?.can_moderate && (
<>
{selectedItem.kind === "request" ? (
<>
<label>
<span>Request status</span>
<select
value={editRequestStatus}
onChange={(event) => setEditRequestStatus(event.target.value)}
>
{REQUEST_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
<span>Media status</span>
<select
value={editMediaStatus}
onChange={(event) => setEditMediaStatus(event.target.value)}
>
{MEDIA_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</>
) : (
<>
<label>
<span>Status</span>
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value)}>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
<span>Issue category</span>
<select value={editIssueType} onChange={(event) => setEditIssueType(event.target.value)}>
{Object.entries(ISSUE_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
</>
)}
<label>
<span>Priority</span>
<select value={editPriority} onChange={(event) => setEditPriority(event.target.value)}>
{PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="portal-field-span-2">
<span>Assignee username</span>
<input
value={editAssignee}
onChange={(event) => setEditAssignee(event.target.value)}
placeholder="Optional assignee"
/>
</label>
</>
)}
<div className="admin-inline-actions portal-field-span-2">
<button type="submit" disabled={saving || !selectedItem.permissions?.can_edit}>
{saving ? "Saving…" : "Save changes"}
</button>
</div>
</form>
{selectedItem.kind === "issue" ? (
<section className="issue-activity-block">
<div className="issue-activity-heading">
<div>
<span className="section-kicker">Recorded work</span>
<h3>Issue activity</h3>
</div>
<span className="small-pill">{activity.length} events</span>
</div>
{activity.length === 0 ? (
<div className="status-banner">No issue activity has been recorded yet.</div>
) : (
<ol className="issue-activity-list">
{activity.map((entry) => (
<li key={entry.id}>
<i aria-hidden="true" />
<div>
<strong>{entry.message}</strong>
<span>
{entry.actor_username} ({entry.actor_role})
</span>
</div>
<time dateTime={entry.created_at}>{formatDate(entry.created_at)}</time>
</li>
))}
</ol>
)}
</section>
) : null}
<div className="portal-comments-block">
<h3>Comments</h3>
{comments.length === 0 ? (
<div className="status-banner">No comments yet.</div>
) : (
<div className="portal-comment-list">
{comments.map((comment) => (
<article key={comment.id} className="portal-comment-card">
<header>
<strong>{comment.author_username}</strong>
<span className="small-pill">{comment.author_role}</span>
{comment.is_internal && <span className="small-pill is-muted">internal</span>}
<span>{formatDate(comment.created_at)}</span>
</header>
<p>{comment.message}</p>
</article>
))}
</div>
)}
<form onSubmit={postComment} className="admin-form compact-form portal-comment-form">
<label>
<span>Add comment</span>
<textarea
rows={3}
value={commentText}
onChange={(event) => setCommentText(event.target.value)}
placeholder="Add an update, troubleshooting note, or next step."
/>
</label>
{isAdmin && (
<label className="inline-checkbox">
<input
type="checkbox"
checked={commentInternal}
onChange={(event) => setCommentInternal(event.target.checked)}
/>
Internal comment (admin only)
</label>
)}
<div className="admin-inline-actions">
<button type="submit" disabled={commenting}>
{commenting ? "Posting…" : "Post comment"}
</button>
</div>
</form>
</div>
</>
)}
</section>
</div>
</div>
</main>
);
}