Files
Magent/frontend/app/portal/PortalClient.tsx
T
Assclaw ec8145a58a
Magent CI/CD / verify (push) Canceled after 6m9s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
Add exact media replacement from issues
2026-08-31 22:05:11 +12:00

2071 lines
81 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type PortalPermissions = {
can_edit?: boolean
can_comment?: boolean
can_moderate?: 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
}
}
type PortalComment = {
id: number
item_id: number
author_username: string
author_role: string
message: string
is_internal: boolean
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'
| '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 ReplacementFile = {
id: number
name: string
quality?: string | null
size?: number | null
season_number?: number | null
episodes?: 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: 'Likely action: replace the affected media file.',
issueType: 'broken_media',
titlePrefix: 'Replace media',
},
{
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: 'Likely action: check the request pipeline, then collect the missing media.',
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: 'Likely action: replace the file or correct its audio tracks.',
issueType: 'audio',
titlePrefix: 'Audio problem',
},
{
id: 'subtitle',
marker: 'SUBS',
label: 'Subtitles are wrong',
description: 'Missing, incorrect, forced, unreadable, or out-of-sync subtitles.',
outcome: 'Likely action: repair the subtitle track or replace the media.',
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 before deciding whether this is file-, device-, or server-related.',
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 the media server and include the result with the report.',
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'],
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 ISSUE_TYPE_LABELS: Record<string, string> = {
general: 'General',
playback: 'Playback',
transcode: 'Transcoding',
service_unavailable: 'Server unavailable',
broken_media: 'Broken media',
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: 'done', label: 'Done' },
{ 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: 'done', label: 'Done' },
{ 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 formatFileSize = (value?: number | null) => {
if (!value || value <= 0) return 'Size unavailable'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const unitIndex = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1)
return `${(value / 1024 ** unitIndex).toFixed(unitIndex >= 3 ? 1 : 0)} ${units[unitIndex]}`
}
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 [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 [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 [issueEpisode, setIssueEpisode] = useState('')
const [issueScope, setIssueScope] = useState<'one_title' | 'multiple_titles' | 'everything'>('one_title')
const [issueSymptom, setIssueSymptom] = useState('')
const [issueDevice, setIssueDevice] = useState('')
const [issueNotes, setIssueNotes] = useState('')
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 [replacementFiles, setReplacementFiles] = useState<ReplacementFile[]>([])
const [replacementFilesLoading, setReplacementFilesLoading] = useState(false)
const [replacementFilesMessage, setReplacementFilesMessage] = useState<string | null>(null)
const [replacementFileFilter, setReplacementFileFilter] = useState('')
const [selectedReplacementFileId, setSelectedReplacementFileId] = useState<number | null>(null)
const [startReplacement, setStartReplacement] = useState(false)
const [replacementAllowed, setReplacementAllowed] = useState(true)
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 issueNeedsMediaTitle =
Boolean(issueCategory) &&
issueCategory !== 'service_unavailable' &&
!(issueCategory === 'playback' && issueScope !== 'one_title')
const issueSupportsReplacement =
issueCategory === 'broken_media' ||
issueCategory === 'audio' ||
issueCategory === 'subtitle' ||
(issueCategory === 'playback' && issueScope === 'one_title')
const selectedReplacementFile =
replacementFiles.find((file) => file.id === selectedReplacementFileId) ?? null
const visibleReplacementFiles = replacementFiles.filter((file) => {
const query = replacementFileFilter.trim().toLowerCase()
if (!query) return true
return [file.name, file.quality, ...(file.episodes ?? [])]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query))
})
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([])
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 : [])
} 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) {
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 loadReplacementFiles = async (media: DiscoveryResult) => {
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementAllowed(true)
setReplacementFileFilter('')
if (!media.requestId) {
setReplacementFilesMessage(
'This title is not linked to an existing Magent request, so there is no managed file to replace.'
)
return
}
setReplacementFilesLoading(true)
setReplacementFilesMessage(null)
try {
const response = await authFetch(
`${getApiBase()}/requests/${media.requestId}/replacement-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 the managed files from Sonarr/Radarr.')
}
const payload = await response.json()
const files = Array.isArray(payload?.files) ? payload.files as ReplacementFile[] : []
setReplacementFiles(files)
setReplacementAllowed(payload?.can_replace !== false)
setReplacementFilesMessage(
payload?.can_replace === false
? 'Your account can report this file, but automatic replacement is disabled.'
: payload?.message ?? null
)
if (files.length === 1 && payload?.can_replace !== false) {
setSelectedReplacementFileId(files[0].id)
}
} catch (err) {
console.error(err)
setReplacementFilesMessage(
err instanceof Error ? err.message : 'Could not load the managed files from Sonarr/Radarr.'
)
} finally {
setReplacementFilesLoading(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([])
setIssueSelectedMedia(null)
setIssueMediaTitle('')
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementAllowed(true)
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) => {
setIssueSelectedMedia(media)
setIssueMediaTitle(media.title)
setIssueMediaType(media.type === 'tv' ? 'tv' : 'movie')
setIssueMediaResults([])
setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ''}`)
setError(null)
if (issueSupportsReplacement) {
void loadReplacementFiles(media)
} else {
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementFilesMessage(null)
}
}
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)
setIssueSymptom(ISSUE_SYMPTOMS[category][0] ?? '')
setIssueScope(category === 'service_unavailable' ? 'everything' : 'one_title')
setMediaServerStatus(null)
setMediaServerError(null)
setError(null)
setStatus(null)
const supportsReplacement = ['broken_media', 'audio', 'subtitle', 'playback'].includes(category)
if (category === 'service_unavailable') {
setIssueSelectedMedia(null)
setIssueMediaTitle('')
setIssueMediaQuery('')
setIssueMediaResults([])
}
if (supportsReplacement && issueSelectedMedia?.requestId) {
void loadReplacementFiles(issueSelectedMedia)
} else if (!supportsReplacement) {
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementFilesMessage(null)
}
if (category === 'playback' || category === 'service_unavailable') {
void checkMediaServer()
}
}
const createGuidedIssue = async (event: React.FormEvent) => {
event.preventDefault()
if (!selectedIssueDefinition || !issueCategory) {
setError('Choose the problem that best matches what you are seeing.')
return
}
const cleanMediaTitle = issueMediaTitle.trim()
if (issueNeedsMediaTitle && (!cleanMediaTitle || !issueSelectedMedia)) {
setError('Search for and select the exact movie or TV show so the managed file can be identified.')
return
}
if (startReplacement && (!issueSelectedMedia?.requestId || !selectedReplacementFile)) {
setError('Select the exact Sonarr/Radarr file before starting a replacement.')
return
}
if (startReplacement && !replacementAllowed) {
setError('Automatic replacement is disabled for this account. The issue can still be submitted.')
return
}
if (
startReplacement &&
selectedReplacementFile &&
!window.confirm(
`Replace “${selectedReplacementFile.name}”?\n\n` +
'The current managed file will be removed and Sonarr/Radarr will immediately search for a replacement. ' +
'The title may be unavailable until the new download is imported.'
)
) {
return
}
setCreating(true)
setError(null)
setStatus(null)
try {
const scopeLabel =
issueScope === 'everything'
? 'Everything / service-wide'
: issueScope === 'multiple_titles'
? 'Multiple titles'
: 'One title'
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}`,
`Symptom: ${issueSymptom}`,
`Scope: ${scopeLabel}`,
cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : null,
cleanMediaTitle ? `Media type: ${issueMediaType === 'tv' ? 'TV show' : 'Movie'}` : null,
issueSelectedMedia?.requestId ? `Magent request: #${issueSelectedMedia.requestId}` : null,
selectedReplacementFile ? `Managed file: ${selectedReplacementFile.name}` : null,
startReplacement ? 'Replacement action: confirmed by the reporting user' : null,
issueEpisode.trim() ? `Season / episode / part: ${issueEpisode.trim()}` : null,
issueDevice.trim() ? `Device or app: ${issueDevice.trim()}` : null,
...diagnosticLines,
issueNotes.trim() ? `Additional information: ${issueNotes.trim()}` : null,
]
.filter((line): line is string => Boolean(line))
.join('\n')
const titleTarget = cleanMediaTitle || (issueScope === 'everything' ? 'all playback' : 'multiple titles')
const resolvedIssueType =
issueCategory === 'playback' && issueSymptom.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 with the troubleshooting details.`
: 'Issue submitted with the troubleshooting details.'
let replacementFailure: string | null = null
if (startReplacement && issueSelectedMedia?.requestId && selectedReplacementFile) {
const replacementResponse = await authFetch(
`${getApiBase()}/requests/${issueSelectedMedia.requestId}/actions/replace`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_id: selectedReplacementFile.id,
confirmed: true,
issue_id: item?.id ?? null,
}),
}
)
if (replacementResponse.ok) {
const replacementPayload = await replacementResponse.json()
completionMessage = `${completionMessage} ${replacementPayload?.message ?? 'Replacement search started.'}`
} else {
let replacementDetail = 'The replacement action could not be started.'
try {
const replacementPayload = await replacementResponse.json()
if (typeof replacementPayload?.detail === 'string') replacementDetail = replacementPayload.detail
} catch {
// Keep the user-safe fallback message.
}
replacementFailure = `The issue was saved, but ${replacementDetail}`
}
}
setStatus(completionMessage)
setError(replacementFailure)
setIssueCategory(null)
setIssueMediaTitle('')
setIssueMediaType('movie')
setIssueEpisode('')
setIssueScope('one_title')
setIssueSymptom('')
setIssueDevice('')
setIssueNotes('')
setMediaServerStatus(null)
setIssueMediaQuery('')
setIssueMediaResults([])
setIssueSelectedMedia(null)
setReplacementFiles([])
setReplacementFilesMessage(null)
setReplacementFileFilter('')
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementAllowed(true)
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([])
// 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 : [])
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)
}
}
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' : ''}`}>
<div className={`user-directory-panel-header ${workspace === 'issue' ? 'issue-portal-hero' : ''}`}>
<div>
{workspace === 'issue' ? <span className="section-kicker">Guided support</span> : null}
<h1>{workspace === 'request' ? 'Request portal' : 'What is going wrong?'}</h1>
<p className="lede">
{workspace === 'request'
? 'Search and track content requests through the delivery pipeline.'
: 'Choose the symptom and Magent will collect the right details, check the media server when relevant, and recommend the next action.'}
</p>
</div>
{workspace === 'issue' ? (
<div className="issue-hero-count">
<strong>{visibleKindCount}</strong>
<span>reported issues</span>
</div>
) : null}
</div>
{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">
<div className="issue-flow-heading">
<span className="issue-step-number">01</span>
<div>
<span className="section-kicker">Choose a symptom</span>
<h2>Which best describes the problem?</h2>
<p>Only the questions needed for that problem will appear next.</p>
</div>
</div>
<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>
<small>{category.outcome}</small>
</button>
))}
</div>
{selectedIssueDefinition && issueCategory ? (
<form className="issue-guided-form" onSubmit={createGuidedIssue}>
<div className="issue-flow-heading">
<span className="issue-step-number">02</span>
<div>
<span className="section-kicker">Narrow it down</span>
<h2>Tell us what is affected</h2>
<p>Magent will attach these details to the issue so nobody has to ask for them again.</p>
</div>
</div>
{issueCategory === 'playback' ? (
<fieldset className="issue-choice-field">
<legend>How widespread is it?</legend>
<div className="issue-choice-row">
{[
['one_title', 'One title'],
['multiple_titles', 'Several titles'],
['everything', 'Nothing will play'],
].map(([value, label]) => (
<button
key={value}
type="button"
className={issueScope === value ? 'is-selected' : ''}
onClick={() => {
const nextScope = value as typeof issueScope
setIssueScope(nextScope)
if (nextScope !== 'one_title') {
setStartReplacement(false)
setSelectedReplacementFileId(null)
} else if (issueSelectedMedia?.requestId) {
void loadReplacementFiles(issueSelectedMedia)
}
}}
>
{label}
</button>
))}
</div>
</fieldset>
) : null}
<div className="issue-question-grid">
{issueNeedsMediaTitle ? (
<div className="issue-media-finder issue-field-span-2">
<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('')
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
}
}}
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}
{issueSelectedMedia && issueSupportsReplacement ? (
<div className="issue-file-picker">
<div>
<span className="section-kicker">Sonarr / Radarr files</span>
<h3>Select the exact file that is faulty</h3>
<p>The selected file is validated again before Magent allows replacement.</p>
</div>
{replacementFilesLoading ? (
<div className="issue-live-scan"><i /><span>Reading managed files from the collector</span></div>
) : null}
{replacementFilesMessage ? <div className="status-banner">{replacementFilesMessage}</div> : null}
{replacementFiles.length > 6 ? (
<input
value={replacementFileFilter}
onChange={(event) => setReplacementFileFilter(event.target.value)}
placeholder="Filter by season, episode, filename, or quality"
/>
) : null}
{visibleReplacementFiles.length > 0 ? (
<div className="issue-file-list">
{visibleReplacementFiles.map((file) => (
<label key={file.id} className={selectedReplacementFileId === file.id ? 'is-selected' : ''}>
<input
type="radio"
name="replacement-file"
checked={selectedReplacementFileId === file.id}
onChange={() => {
setSelectedReplacementFileId(file.id)
setStartReplacement(false)
}}
/>
<span>
<strong>{file.episodes?.length ? file.episodes.join(', ') : issueSelectedMedia.title}</strong>
<small>{file.name}</small>
</span>
<b>{[file.quality, formatFileSize(file.size)].filter(Boolean).join(' · ')}</b>
</label>
))}
</div>
) : null}
{selectedReplacementFile && replacementAllowed ? (
<label className="issue-replacement-toggle">
<input
type="checkbox"
checked={startReplacement}
onChange={(event) => setStartReplacement(event.target.checked)}
/>
<span>
<strong>Replace this file after submitting the issue</strong>
<small>
Removes only this managed file, then immediately starts a replacement search through {issueMediaType === 'tv' ? 'Sonarr' : 'Radarr'}.
</small>
</span>
</label>
) : null}
</div>
) : null}
</div>
) : null}
{issueNeedsMediaTitle && issueMediaType === 'tv' ? (
<label>
<span>Season / episode</span>
<input
value={issueEpisode}
onChange={(event) => setIssueEpisode(event.target.value)}
placeholder="For example S02 E04"
/>
</label>
) : null}
<label className={issueNeedsMediaTitle && issueMediaType === 'tv' ? 'issue-field-span-2' : ''}>
<span>What happens?</span>
<select value={issueSymptom} onChange={(event) => setIssueSymptom(event.target.value)}>
{ISSUE_SYMPTOMS[issueCategory].map((symptom) => (
<option key={symptom} value={symptom}>{symptom}</option>
))}
</select>
</label>
{(issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? (
<label>
<span>Device or app</span>
<input
value={issueDevice}
onChange={(event) => setIssueDevice(event.target.value)}
placeholder="For example Samsung TV or Chrome"
/>
</label>
) : null}
<label className="issue-field-span-2">
<span>Anything else we should know?</span>
<textarea
rows={3}
value={issueNotes}
onChange={(event) => setIssueNotes(event.target.value)}
placeholder="Optional error message, timestamp, language, edition, or anything unusual"
/>
</label>
</div>
{issueNeedsServerCheck ? (
<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}
<section className="issue-resolution-card">
<span className="issue-step-number">03</span>
<div>
<span className="section-kicker">Recommended path</span>
<h2>{selectedIssueDefinition.outcome.replace('Likely action: ', '')}</h2>
<p>
{startReplacement && selectedReplacementFile
? `After confirmation, ${selectedReplacementFile.name} will be removed and the collector will start searching immediately.`
: issueNeedsServerCheck
? 'The live check above will be saved in the report, giving administrators immediate context.'
: 'Submit this report and it will arrive with the replacement or collection path already identified.'}
</p>
</div>
<button type="submit" disabled={creating || mediaServerChecking}>
{creating ? 'Working...' : startReplacement ? 'Submit issue & replace file' : 'Submit issue'}
</button>
</section>
</form>
) : null}
</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}
{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>
<label className="portal-search-filter">
<span>Search</span>
<input
value={filterSearch}
onChange={(event) => setFilterSearch(event.target.value)}
placeholder={
workspace === 'request'
? 'Search request items by title, description, or id'
: 'Search issue items by title, description, or id'
}
/>
</label>
<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>
<div className="portal-item-row-meta">
<span>#{item.id}</span>
<span>
Status:{' '}
{item.kind === 'request'
? item.workflow?.stage_label ?? item.status
: item.status}
</span>
<span>By: {item.created_by_username}</span>
<span>Updated: {formatDate(item.last_activity_at)}</span>
</div>
</div>
</button>
))}
</div>
)}
</section>
<section className="admin-panel portal-detail-panel">
{!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' && 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>
<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>
</main>
)
}