2745 lines
113 KiB
TypeScript
2745 lines
113 KiB
TypeScript
"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. We’ll 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>
|
||
);
|
||
}
|