2546 lines
104 KiB
TypeScript
2546 lines
104 KiB
TypeScript
'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. We’ll 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>
|
||
)
|
||
}
|