Files
Magent/frontend/app/portal/PortalClient.tsx
T
Assclaw 8d720de500
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 2m2s
Streamline issue wizard and make selections explicit
2026-09-06 21:44:44 +12:00

2546 lines
104 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import 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 raw = new URLSearchParams(window.location.search).get('item')
if (!raw) {
setPreselectedItemId(null)
return
}
const parsed = Number.parseInt(raw, 10)
setPreselectedItemId(Number.isNaN(parsed) || parsed <= 0 ? null : parsed)
}, [])
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`)
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: any) => ({
title: 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: item?.statusLabel ?? null,
status: typeof item?.status === 'number' ? item.status : null,
accessible: Boolean(item?.accessible),
posterPath: item?.posterPath ?? null,
backdropPath: 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
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: any) => item?.type === 'movie' || item?.type === 'tv')
.map((item: any) => ({
title: 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: item?.statusLabel ?? null,
status: typeof item?.status === 'number' ? item.status : null,
accessible: Boolean(item?.accessible),
posterPath: item?.posterPath ?? null,
backdropPath: 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)
setIssueStep(issueSelectedMedia && issueOptions ? 'symptoms' : 'media')
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
if (category === 'playback' || category === 'service_unavailable') {
void checkMediaServer()
}
}
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)
}
}
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()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router])
useEffect(() => {
if (!getToken()) {
return
}
void loadItems({ preferItemId: preselectedItemId })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterStatus, filterMine, filterSearch, workspace])
useEffect(() => {
setFilterStatus('')
setCreateMediaType('')
setCreateYear('')
setSelectedItemId(null)
setSelectedItem(null)
setComments([])
setActivity([])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspace])
useEffect(() => {
if (selectedItemId == null) return
void loadItem(selectedItemId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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, any> = {
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, any> = {
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(() => {
setDeleteConfirming(false)
}, [selectedItemId])
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)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspace, selectedItemId])
if (loadingItems && !items.length) {
return <main className="card">Loading {workspace === 'issue' ? 'issues' : 'requests'}...</main>
}
return (
<main className={`card portal-page ${workspace === 'issue' ? 'issue-portal-page' : ''}`}>
<PageHeading
title={workspace === 'request' ? 'Request portal' : 'Issues'}
description={workspace === 'request' ? 'Search and track your content requests.' : 'Tell us what is wrong. Well guide you through the fix.'}
actions={workspace === 'issue' ? <span className="page-heading-meta">{visibleKindCount} reported {visibleKindCount === 1 ? 'issue' : 'issues'}</span> : undefined}
/>
{workspace === '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 ?? ''}>
<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>
) : (
<>
<div className="user-directory-panel-header">
<div>
<h2>
{selectedItem.kind === 'request' ? 'Request' : 'Issue'} #{selectedItem.id}
</h2>
<p className="lede">
Created by {selectedItem.created_by_username} 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}
{selectedItem.kind === 'issue' && selectedItem.status === 'awaiting_confirmation' ? (
<section className="issue-confirmation-card" aria-live="polite">
<div>
<span className="section-kicker">Resolution check</span>
<h3>Has this issue been fixed?</h3>
<p>
Magent is waiting for the reporter to confirm the result.
{(selectedItem.issue?.confirmation?.maximum_attempts ?? 0) > 0
? ` ${selectedItem.issue?.confirmation?.attempts_sent ?? 0} of ${selectedItem.issue?.confirmation?.maximum_attempts ?? 0} confirmation emails have been attempted.`
: ' Confirmation emails are disabled, so this issue will close automatically.'}
</p>
{selectedItem.issue?.confirmation?.next_contact_at ? (
<small>Next reminder or automatic closure check: {formatDate(selectedItem.issue.confirmation.next_contact_at)}</small>
) : null}
</div>
{selectedItem.permissions?.can_confirm_resolution ? (
<div className="issue-confirmation-actions">
<button type="button" disabled={respondingResolution} onClick={() => void respondToResolution(true)}>
Yes, it is fixed
</button>
<button type="button" className="ghost-button" disabled={respondingResolution} onClick={() => void respondToResolution(false)}>
No, it is still happening
</button>
</div>
) : null}
</section>
) : 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>
)
}