Streamline issue wizard and make selections explicit
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 2m2s

This commit is contained in:
2026-09-06 21:44:44 +12:00
parent 1851fa9753
commit 8d720de500
6 changed files with 681 additions and 321 deletions
+375 -321
View File
@@ -1,9 +1,10 @@
'use client'
import PageHeading from '../ui/PageHeading'
import IssueFlowStep from './IssueFlowStep'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type PortalPermissions = {
@@ -270,6 +271,7 @@ const ISSUE_SYMPTOMS: Record<IssueCategoryId, string[]> = {
}
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',
@@ -502,7 +504,8 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const [issueOptions, setIssueOptions] = useState<IssueTargetOptions | null>(null)
const [issueOptionsLoading, setIssueOptionsLoading] = useState(false)
const [issueOptionsMessage, setIssueOptionsMessage] = useState<string | null>(null)
const [movieTargetSelected, setMovieTargetSelected] = useState(false)
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[]>([])
@@ -513,7 +516,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
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)
const issueNeedsDevices = issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle'
const issueRequiresExistingFile =
issueCategory === 'broken_media' ||
issueCategory === 'wrong_content' ||
@@ -549,7 +552,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
issueSymptoms.length > 0 &&
(
issueOptions.request_type === 'movie'
? movieTargetSelected && movieTargetAvailable
? movieTargetAvailable
: issueCategory === 'missing_content'
? (
missingEntireTitle ||
@@ -559,6 +562,26 @@ export default function PortalClient({ workspace }: PortalClientProps) {
: 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
@@ -807,8 +830,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
}
const loadIssueOptions = async (media: DiscoveryResult) => {
const version = ++issueOptionsVersion.current
setIssueOptionsLoading(false)
setIssueOptions(null)
setMovieTargetSelected(false)
setActiveSeasonNumber(null)
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
@@ -834,17 +858,20 @@ export default function PortalClient({ workspace }: PortalClientProps) {
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 {
setIssueOptionsLoading(false)
if (version === issueOptionsVersion.current) setIssueOptionsLoading(false)
}
}
@@ -858,10 +885,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setIssueMediaSearching(true)
setError(null)
setIssueMediaResults([])
issueOptionsVersion.current += 1
setIssueOptionsLoading(false)
setIssueSelectedMedia(null)
setIssueMediaTitle('')
setIssueOptions(null)
setMovieTargetSelected(false)
setActiveSeasonNumber(null)
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
@@ -907,6 +935,8 @@ export default function PortalClient({ workspace }: PortalClientProps) {
}
const selectIssueMedia = (media: DiscoveryResult) => {
setIssueSymptoms([])
setIssueDevices([])
setIssueSelectedMedia(media)
setIssueMediaTitle(media.title)
setIssueMediaType(media.type === 'tv' ? 'tv' : 'movie')
@@ -950,7 +980,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setMediaServerError(null)
setError(null)
setStatus(null)
setMovieTargetSelected(false)
setIssueStep(issueSelectedMedia && issueOptions ? 'symptoms' : 'media')
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
if (category === 'playback' || category === 'service_unavailable') {
@@ -960,10 +990,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const toggleStringChoice = (
value: string,
selected: string[],
setter: React.Dispatch<React.SetStateAction<string[]>>,
) => {
setter(selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value])
setter((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value])
}
const runIssueAction = async (path: string, body: Record<string, unknown>): Promise<string> => {
@@ -988,6 +1017,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
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
@@ -1002,8 +1032,8 @@ export default function PortalClient({ workspace }: PortalClientProps) {
return
}
const isMovie = issueOptions.request_type === 'movie'
if (isMovie && !movieTargetSelected) {
setError('Select the movie to continue.')
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) {
@@ -1142,7 +1172,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setIssueSelectedMedia(null)
setIssueOptions(null)
setIssueOptionsMessage(null)
setMovieTargetSelected(false)
setIssueStep('problem')
setActiveSeasonNumber(null)
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
@@ -1565,346 +1595,370 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</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>
</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>
<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>
</div>
</IssueFlowStep>
<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('')
setIssueOptions(null)
setMovieTargetSelected(false)
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
{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>
{issueOptionsLoading ? (
<div className="issue-live-scan"><i /><span>Reading seasons and episodes</span></div>
) : null}
{issueOptionsMessage && !issueOptions ? <div className="status-banner">{issueOptionsMessage}</div> : null}
{issueOptions ? (
<div className="issue-target-picker">
{issueOptions ? (
<>
<IssueFlowStep {...stepProps('symptoms')} title="What needs to be corrected?" summary={issueSymptoms.join(', ')}>
<fieldset className="issue-choice-field">
<legend>What needs to be corrected?</legend>
<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"
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) {
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([])
}
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([])
} 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>{selected ? '✓' : '+'}</span>
<strong>{issueOptions.request_type === 'movie' && symptom === 'Entire title is missing' ? 'Movie is missing' : symptom}</strong>
</button>
)
})}
}}
>
<span aria-hidden="true">{selected ? '✓' : '+'}</span>
<strong>{issueOptions.request_type === 'movie' && symptom === 'Entire title is missing' ? 'Movie is missing' : symptom}</strong>
</button>
)
})}
</div>
</fieldset>
{issueSymptoms.length > 0 && issueOptions.request_type === 'movie' ? (
<button
type="button"
className={`issue-movie-target ${movieTargetSelected ? 'is-selected' : ''}`}
disabled={!movieTargetAvailable}
onClick={() => setMovieTargetSelected((current) => !current)}
>
<span>{movieTargetSelected ? '✓' : 'MOVIE'}</span>
<div>
<strong>{issueOptions.title}</strong>
<small>
{!movieTargetAvailable
? 'No managed file is available for this repair'
: issueOptions.movie?.best_fit
? 'This is the best fit'
: issueOptions.movie?.has_file
? 'Ready to select'
: 'Missing in Radarr'}
</small>
</div>
</button>
{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>
{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"
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>
)
})}
{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>
) : null}
{(issueCategory !== 'missing_content' || missingEpisodes) ? (
<div className="issue-season-grid issue-season-tabs">
{issueOptions.seasons.map((season) => (
<button
key={season.season_number}
type="button"
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
{missingSeasons ? (
<div className="issue-season-grid">
{issueOptions.seasons.map((season) => {
const selected = selectedSeasonNumbers.includes(season.season_number)
return (
<button
key={episode.id}
key={season.season_number}
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])}
onClick={() => setSelectedSeasonNumbers((current) => current.includes(season.season_number)
? current.filter((value) => value !== season.season_number)
: [...current, season.season_number])}
>
<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}
<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}
</div>
) : null}
</div>
) : null}
</div>
) : null}
{issueTargetReady && (issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? (
<fieldset className="issue-choice-field issue-field-span-2">
<legend>Where did it happen? <small>Choose all that apply</small></legend>
<div className="issue-choice-row">
{DEVICE_OPTIONS.map((device) => (
<button
key={device}
type="button"
className={issueDevices.includes(device) ? 'is-selected' : ''}
onClick={() => toggleStringChoice(device, issueDevices, setIssueDevices)}
>
{device}
</button>
))}
</div>
</fieldset>
) : null}
</div>
</div>
) : null}
{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>
{(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}
</section>
</>
) : null}
{issueTargetReady ? <section className="issue-resolution-card">
<span className="issue-step-number">03</span>
<div>
<span className="section-kicker">What will happen</span>
<h2>
{issueOptions?.can_act === false
? 'The selected details will be sent to support.'
: selectedIssueDefinition.outcome}
</h2>
<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...' : 'Submit and start fix'}
</button>
</section> : null}
</form>
) : null}
</fieldset>
</form>
</section>
)}