Build guided media issue workflow
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s

This commit is contained in:
2026-08-31 21:46:43 +12:00
parent 0e04d219a0
commit f8770cb44a
5 changed files with 1124 additions and 37 deletions
+372
View File
@@ -2514,3 +2514,375 @@ button:disabled,
.request-submit-progress p > small { grid-column: 2; }
.request-complete-actions { display: grid; }
}
/* Guided issue reporting */
.issue-portal-page {
gap: 20px;
}
.issue-portal-page > .issue-portal-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 0;
padding: 22px 24px;
background:
radial-gradient(circle at 100% 0%, rgba(126, 215, 255, 0.11), transparent 42%),
linear-gradient(135deg, rgba(90, 80, 240, 0.12), transparent 48%),
var(--ops-panel);
}
.issue-portal-hero > div:first-child {
display: grid;
gap: 5px;
max-width: 790px;
}
.issue-portal-hero h1 {
font-size: clamp(1.65rem, 3vw, 2.45rem);
letter-spacing: -0.035em;
}
.issue-portal-hero .lede {
max-width: 760px;
margin: 0;
}
.issue-hero-count {
display: grid;
flex: 0 0 auto;
min-width: 130px;
padding: 12px 16px;
border: 1px solid var(--ops-line-soft);
border-radius: var(--ops-radius);
background: rgba(255, 255, 255, 0.025);
}
.issue-hero-count strong {
color: var(--ops-cyan);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 1.45rem;
}
.issue-hero-count span {
color: var(--ops-muted);
font-size: 0.7rem;
text-transform: uppercase;
}
.issue-flow {
display: grid;
gap: 18px;
padding: 20px;
border: 1px solid var(--ops-line);
border-radius: var(--ops-radius-lg);
background: rgba(255, 255, 255, 0.018);
}
.issue-flow-heading {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 13px;
}
.issue-flow-heading > div {
display: grid;
gap: 4px;
}
.issue-flow-heading h2,
.issue-resolution-card h2,
.issue-history-heading h2,
.media-status-heading h3 {
margin: 0;
}
.issue-flow-heading p,
.issue-resolution-card p,
.media-status-check > p {
margin: 0;
color: var(--ops-muted);
line-height: 1.5;
}
.issue-step-number {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border: 1px solid rgba(126, 215, 255, 0.34);
border-radius: 50%;
background: rgba(14, 165, 233, 0.08);
color: var(--ops-cyan);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.7rem;
font-weight: 800;
}
.issue-category-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.issue-category-card {
display: grid;
align-content: start;
gap: 8px;
min-height: 190px;
padding: 16px;
border: 1px solid var(--ops-line-soft);
border-radius: var(--ops-radius-lg);
background: rgba(255, 255, 255, 0.022);
color: var(--ops-text);
text-align: left;
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
.issue-category-card:hover {
transform: translateY(-2px);
border-color: rgba(126, 215, 255, 0.42);
}
.issue-category-card.is-selected {
border-color: rgba(72, 224, 178, 0.58);
background: linear-gradient(145deg, rgba(72, 224, 178, 0.09), rgba(14, 165, 233, 0.035));
box-shadow: 0 0 24px rgba(72, 224, 178, 0.06);
}
.issue-category-card strong {
font-size: 0.98rem;
}
.issue-category-card p,
.issue-category-card small {
margin: 0;
color: var(--ops-muted);
font-size: 0.75rem;
font-weight: 500;
line-height: 1.45;
}
.issue-category-card small {
align-self: end;
margin-top: auto;
padding-top: 9px;
border-top: 1px solid var(--ops-line-soft);
color: var(--request-green);
font-size: 0.68rem;
}
.issue-category-marker {
justify-self: start;
padding: 4px 7px;
border: 1px solid rgba(126, 215, 255, 0.24);
border-radius: 999px;
color: var(--ops-cyan);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.58rem;
letter-spacing: 0.05em;
}
.issue-guided-form {
display: grid;
gap: 18px;
padding-top: 20px;
border-top: 1px solid var(--ops-line);
}
.issue-choice-field {
display: grid;
gap: 9px;
margin: 0;
padding: 0;
border: 0;
}
.issue-choice-field legend,
.issue-question-grid label > span {
margin-bottom: 7px;
color: var(--ops-muted);
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.issue-choice-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.issue-choice-row button {
border-color: var(--ops-line-soft);
background: rgba(255, 255, 255, 0.025);
color: var(--ops-muted);
}
.issue-choice-row button.is-selected {
border-color: rgba(126, 215, 255, 0.48);
background: rgba(14, 165, 233, 0.12);
color: var(--ops-text);
}
.issue-question-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.issue-question-grid label {
display: grid;
align-content: start;
}
.issue-field-span-2 {
grid-column: span 2;
}
.media-status-check {
display: grid;
gap: 13px;
padding: 16px;
border: 1px solid rgba(126, 215, 255, 0.28);
border-radius: var(--ops-radius-lg);
background: rgba(14, 165, 233, 0.045);
}
.media-status-check.media-status-up {
border-color: rgba(72, 224, 178, 0.34);
background: rgba(72, 224, 178, 0.045);
}
.media-status-check.media-status-degraded {
border-color: rgba(255, 192, 84, 0.36);
background: rgba(255, 192, 84, 0.05);
}
.media-status-check.media-status-down {
border-color: rgba(255, 86, 113, 0.38);
background: rgba(255, 86, 113, 0.05);
}
.media-status-heading,
.issue-history-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.media-status-heading > div,
.issue-history-heading > div {
display: grid;
gap: 4px;
}
.issue-live-scan {
display: flex;
align-items: center;
gap: 9px;
color: var(--ops-muted);
font-size: 0.75rem;
}
.issue-live-scan i {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--ops-cyan);
box-shadow: 0 0 14px var(--ops-cyan);
animation: request-operation-pulse 1.15s ease-in-out infinite;
}
.media-status-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.media-status-metrics > div {
display: grid;
gap: 3px;
min-width: 0;
padding: 10px;
border: 1px solid var(--ops-line-soft);
border-radius: var(--ops-radius);
background: rgba(255, 255, 255, 0.022);
}
.media-status-metrics span {
color: var(--ops-muted);
font-size: 0.62rem;
text-transform: uppercase;
}
.media-status-metrics strong {
overflow: hidden;
font-size: 0.75rem;
text-overflow: ellipsis;
}
.issue-resolution-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 16px;
border: 1px solid rgba(72, 224, 178, 0.3);
border-radius: var(--ops-radius-lg);
background: linear-gradient(110deg, rgba(72, 224, 178, 0.07), rgba(14, 165, 233, 0.035));
}
.issue-resolution-card > div {
display: grid;
gap: 4px;
}
.issue-resolution-card > button {
min-width: 150px;
}
.issue-history-heading {
padding-top: 4px;
}
.issue-history-heading > span {
padding: 5px 9px;
border: 1px solid var(--ops-line-soft);
border-radius: 999px;
color: var(--ops-muted);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.65rem;
}
.issue-portal-page .portal-item-row p {
display: -webkit-box;
overflow: hidden;
white-space: pre-wrap;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
@media (max-width: 980px) {
.issue-category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.media-status-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.issue-portal-page > .issue-portal-hero,
.media-status-heading,
.issue-history-heading { align-items: flex-start; flex-direction: column; }
.issue-hero-count { width: 100%; }
.issue-flow { padding: 14px; }
.issue-category-grid,
.issue-question-grid,
.media-status-metrics { grid-template-columns: 1fr; }
.issue-field-span-2 { grid-column: span 1; }
.issue-category-card { min-height: 0; }
.issue-resolution-card { grid-template-columns: auto minmax(0, 1fr); }
.issue-resolution-card > button { grid-column: 1 / -1; width: 100%; }
}
+523 -37
View File
@@ -80,6 +80,119 @@ type DiscoveryResult = {
backdropPath?: string | null
}
type IssueCategoryId =
| 'broken_media'
| 'missing_content'
| 'audio'
| 'subtitle'
| 'playback'
| 'service_unavailable'
type MediaServerStatus = {
checked_at?: string
status: 'up' | 'degraded' | 'down' | 'not_configured'
headline: string
message: string
latency_ms?: number | null
server?: {
version?: string | null
restart_pending?: boolean | null
}
activity?: {
active_streams?: number | null
transcoding_streams?: number | null
available?: boolean
}
}
const ISSUE_CATEGORIES: Array<{
id: IssueCategoryId
marker: string
label: string
description: string
outcome: string
issueType: string
titlePrefix: string
}> = [
{
id: 'broken_media',
marker: 'REPLACE',
label: 'Picture or file is broken',
description: 'Corruption, visual artefacts, freezing, or playback stopping at the same point.',
outcome: 'Likely action: replace the affected media file.',
issueType: 'broken_media',
titlePrefix: 'Replace media',
},
{
id: 'missing_content',
marker: 'MISSING',
label: 'Movie or episode is missing',
description: 'A title, season, episode, or expected part is not available in Grizzlyflix.',
outcome: 'Likely action: check the request pipeline, then collect the missing media.',
issueType: 'missing_content',
titlePrefix: 'Missing content',
},
{
id: 'audio',
marker: 'AUDIO',
label: 'Audio is wrong',
description: 'No sound, wrong language, commentary only, distorted audio, or audio out of sync.',
outcome: 'Likely action: replace the file or correct its audio tracks.',
issueType: 'audio',
titlePrefix: 'Audio problem',
},
{
id: 'subtitle',
marker: 'SUBS',
label: 'Subtitles are wrong',
description: 'Missing, incorrect, forced, unreadable, or out-of-sync subtitles.',
outcome: 'Likely action: repair the subtitle track or replace the media.',
issueType: 'subtitle',
titlePrefix: 'Subtitle problem',
},
{
id: 'playback',
marker: 'PLAYBACK',
label: 'Playback or transcoding problem',
description: 'The title will not start, constantly buffers, stops, or reports a transcode error.',
outcome: 'Magent will check Jellyfin before deciding whether this is file-, device-, or server-related.',
issueType: 'playback',
titlePrefix: 'Playback problem',
},
{
id: 'service_unavailable',
marker: 'SERVER',
label: 'Nothing will play',
description: 'Grizzlyflix will not open or every title fails across the device or household.',
outcome: 'Magent will check the media server and include the result with the report.',
issueType: 'service_unavailable',
titlePrefix: 'Media server unavailable',
},
]
const ISSUE_SYMPTOMS: Record<IssueCategoryId, string[]> = {
broken_media: ['Visual artefacts or corruption', 'Freezes at the same point', 'Stops before the end', 'File will not play'],
missing_content: ['Entire title is missing', 'Season is missing', 'Episode is missing', 'Part or edition is missing'],
audio: ['No audio', 'Wrong language', 'Commentary track only', 'Audio is out of sync', 'Audio is distorted'],
subtitle: ['Subtitles are missing', 'Wrong subtitles', 'Subtitles are out of sync', 'Forced subtitles are missing'],
playback: ['Will not start', 'Constant buffering', 'Transcode error', 'Stops during playback', 'Only fails on one device'],
service_unavailable: ['Grizzlyflix will not open', 'Every title fails', 'Login works but playback does not', 'Server error is shown'],
}
const ISSUE_TYPE_LABELS: Record<string, string> = {
general: 'General',
playback: 'Playback',
transcode: 'Transcoding',
service_unavailable: 'Server unavailable',
broken_media: 'Broken media',
missing_content: 'Missing content',
audio: 'Audio',
subtitle: 'Subtitles',
quality: 'Quality',
metadata: 'Metadata',
other: 'Other',
}
const STATUS_OPTIONS = [
{ value: 'new', label: 'New' },
{ value: 'triaging', label: 'Triaging' },
@@ -200,6 +313,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
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')
@@ -213,11 +327,28 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const [discoverResults, setDiscoverResults] = useState<DiscoveryResult[]>([])
const [discoverError, setDiscoverError] = useState<string | null>(null)
const [requestingTmdbIds, setRequestingTmdbIds] = useState<Record<string, boolean>>({})
const [issueCategory, setIssueCategory] = useState<IssueCategoryId | null>(null)
const [issueMediaTitle, setIssueMediaTitle] = useState('')
const [issueMediaType, setIssueMediaType] = useState<'movie' | 'tv'>('movie')
const [issueEpisode, setIssueEpisode] = useState('')
const [issueScope, setIssueScope] = useState<'one_title' | 'multiple_titles' | 'everything'>('one_title')
const [issueSymptom, setIssueSymptom] = useState('')
const [issueDevice, setIssueDevice] = useState('')
const [issueNotes, setIssueNotes] = useState('')
const [mediaServerStatus, setMediaServerStatus] = useState<MediaServerStatus | null>(null)
const [mediaServerChecking, setMediaServerChecking] = useState(false)
const [mediaServerError, setMediaServerError] = useState<string | null>(null)
const isAdmin = me?.role === 'admin'
const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0)
const workspaceLabel = workspace === 'request' ? 'request' : 'issue'
const workspaceLabelPlural = workspace === 'request' ? 'requests' : 'issues'
const selectedIssueDefinition = ISSUE_CATEGORIES.find((category) => category.id === issueCategory) ?? null
const issueNeedsServerCheck = issueCategory === 'playback' || issueCategory === 'service_unavailable'
const issueNeedsMediaTitle =
Boolean(issueCategory) &&
issueCategory !== 'service_unavailable' &&
!(issueCategory === 'playback' && issueScope === 'everything')
useEffect(() => {
if (typeof window === 'undefined') return
@@ -463,6 +594,158 @@ export default function PortalClient({ workspace }: PortalClientProps) {
}
}
const checkMediaServer = async () => {
setMediaServerChecking(true)
setMediaServerError(null)
try {
const response = await authFetch(`${getApiBase()}/portal/issues/media-status`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error('The live media-server check is temporarily unavailable.')
}
const payload = (await response.json()) as MediaServerStatus
setMediaServerStatus(payload)
} catch (err) {
console.error(err)
setMediaServerStatus(null)
setMediaServerError(
err instanceof Error ? err.message : 'The live media-server check is temporarily unavailable.'
)
} finally {
setMediaServerChecking(false)
}
}
const chooseIssueCategory = (category: IssueCategoryId) => {
setIssueCategory(category)
setIssueSymptom(ISSUE_SYMPTOMS[category][0] ?? '')
setIssueScope(category === 'service_unavailable' ? 'everything' : 'one_title')
setMediaServerStatus(null)
setMediaServerError(null)
setError(null)
setStatus(null)
if (category === 'playback' || category === 'service_unavailable') {
void checkMediaServer()
}
}
const createGuidedIssue = async (event: React.FormEvent) => {
event.preventDefault()
if (!selectedIssueDefinition || !issueCategory) {
setError('Choose the problem that best matches what you are seeing.')
return
}
const cleanMediaTitle = issueMediaTitle.trim()
if (issueNeedsMediaTitle && !cleanMediaTitle) {
setError('Enter the affected movie or TV show so the file can be identified.')
return
}
setCreating(true)
setError(null)
setStatus(null)
try {
const scopeLabel =
issueScope === 'everything'
? 'Everything / service-wide'
: issueScope === 'multiple_titles'
? 'Multiple titles'
: 'One title'
const diagnosticLines: string[] = []
if (mediaServerStatus) {
diagnosticLines.push(
`Media server check: ${mediaServerStatus.headline}`,
`Checked: ${formatDate(mediaServerStatus.checked_at)}`,
)
if (typeof mediaServerStatus.latency_ms === 'number') {
diagnosticLines.push(`Response time: ${mediaServerStatus.latency_ms} ms`)
}
if (mediaServerStatus.server?.restart_pending) {
diagnosticLines.push('Server restart pending: yes')
}
if (mediaServerStatus.activity?.available) {
diagnosticLines.push(
`Active streams: ${mediaServerStatus.activity.active_streams ?? 0}`,
`Active transcodes: ${mediaServerStatus.activity.transcoding_streams ?? 0}`,
)
}
} else if (issueNeedsServerCheck) {
diagnosticLines.push('Media server check: unavailable at the time of reporting')
}
const description = [
`Problem: ${selectedIssueDefinition.label}`,
`Symptom: ${issueSymptom}`,
`Scope: ${scopeLabel}`,
cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : null,
cleanMediaTitle ? `Media type: ${issueMediaType === 'tv' ? 'TV show' : 'Movie'}` : null,
issueEpisode.trim() ? `Season / episode / part: ${issueEpisode.trim()}` : null,
issueDevice.trim() ? `Device or app: ${issueDevice.trim()}` : null,
...diagnosticLines,
issueNotes.trim() ? `Additional information: ${issueNotes.trim()}` : null,
]
.filter((line): line is string => Boolean(line))
.join('\n')
const titleTarget = cleanMediaTitle || (issueScope === 'everything' ? 'all playback' : 'multiple titles')
const resolvedIssueType =
issueCategory === 'playback' && issueSymptom.toLowerCase().includes('transcode')
? 'transcode'
: selectedIssueDefinition.issueType
const priority =
mediaServerStatus?.status === 'down' || issueCategory === 'service_unavailable'
? 'high'
: 'normal'
const response = await authFetch(`${getApiBase()}/portal/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'issue',
title: `${selectedIssueDefinition.titlePrefix}: ${titleTarget}`,
description,
issue_type: resolvedIssueType,
media_type: cleanMediaTitle ? issueMediaType : null,
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
setStatus(
item?.id
? `Issue #${item.id} submitted with the troubleshooting details.`
: 'Issue submitted with the troubleshooting details.'
)
setIssueCategory(null)
setIssueMediaTitle('')
setIssueMediaType('movie')
setIssueEpisode('')
setIssueScope('one_title')
setIssueSymptom('')
setIssueDevice('')
setIssueNotes('')
setMediaServerStatus(null)
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')
@@ -514,6 +797,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
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')
@@ -591,6 +875,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
payload.media_status = editMediaStatus
} else {
payload.status = editStatus
payload.issue_type = editIssueType
}
payload.priority = editPriority
payload.assignee_username = editAssignee || null
@@ -672,40 +957,39 @@ export default function PortalClient({ workspace }: PortalClientProps) {
}
if (loadingItems && !items.length) {
return <main className="card">Loading request portal...</main>
return <main className="card">Loading {workspace === 'issue' ? 'issues' : 'requests'}...</main>
}
return (
<main className="card portal-page">
<div className="user-directory-panel-header">
<main className={`card portal-page ${workspace === 'issue' ? 'issue-portal-page' : ''}`}>
<div className={`user-directory-panel-header ${workspace === 'issue' ? 'issue-portal-hero' : ''}`}>
<div>
<h1>{workspace === 'request' ? 'Request portal' : 'Issue portal'}</h1>
{workspace === 'issue' ? <span className="section-kicker">Guided support</span> : null}
<h1>{workspace === 'request' ? 'Request portal' : 'What is going wrong?'}</h1>
<p className="lede">
{workspace === 'request'
? 'Search and track content requests through the delivery pipeline.'
: 'Raise operational issues and manage resolution updates.'}
: 'Choose the symptom and Magent will collect the right details, check the media server when relevant, and recommend the next action.'}
</p>
</div>
{workspace === 'issue' ? (
<div className="issue-hero-count">
<strong>{visibleKindCount}</strong>
<span>reported issues</span>
</div>
) : null}
</div>
<section className="portal-workspace-switch">
<button
type="button"
className={workspace === 'request' ? 'is-active' : ''}
onClick={() => router.push('/new-requests')}
disabled={workspace === 'request'}
>
New requests
</button>
<button
type="button"
className={workspace === 'issue' ? 'is-active' : ''}
onClick={() => router.push('/portal/issues')}
disabled={workspace === 'issue'}
>
Issues
</button>
</section>
{workspace === 'request' ? (
<section className="portal-workspace-switch">
<button type="button" className="is-active" disabled>
New requests
</button>
<button type="button" onClick={() => router.push('/portal/issues')}>
Issues
</button>
</section>
) : null}
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
@@ -790,13 +1074,180 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
</section>
) : (
<section className="admin-panel">
<div className="status-banner">
Issue workspace is for reporting problems and tracking resolution separately from content requests.
<section className="issue-flow">
<div className="issue-flow-heading">
<span className="issue-step-number">01</span>
<div>
<span className="section-kicker">Choose a symptom</span>
<h2>Which best describes the problem?</h2>
<p>Only the questions needed for that problem will appear next.</p>
</div>
</div>
<div className="issue-category-grid">
{ISSUE_CATEGORIES.map((category) => (
<button
key={category.id}
type="button"
className={`issue-category-card ${issueCategory === category.id ? 'is-selected' : ''}`}
onClick={() => chooseIssueCategory(category.id)}
>
<span className="issue-category-marker">{category.marker}</span>
<strong>{category.label}</strong>
<p>{category.description}</p>
<small>{category.outcome}</small>
</button>
))}
</div>
{selectedIssueDefinition && issueCategory ? (
<form className="issue-guided-form" onSubmit={createGuidedIssue}>
<div className="issue-flow-heading">
<span className="issue-step-number">02</span>
<div>
<span className="section-kicker">Narrow it down</span>
<h2>Tell us what is affected</h2>
<p>Magent will attach these details to the issue so nobody has to ask for them again.</p>
</div>
</div>
{issueCategory === 'playback' ? (
<fieldset className="issue-choice-field">
<legend>How widespread is it?</legend>
<div className="issue-choice-row">
{[
['one_title', 'One title'],
['multiple_titles', 'Several titles'],
['everything', 'Nothing will play'],
].map(([value, label]) => (
<button
key={value}
type="button"
className={issueScope === value ? 'is-selected' : ''}
onClick={() => setIssueScope(value as typeof issueScope)}
>
{label}
</button>
))}
</div>
</fieldset>
) : null}
<div className="issue-question-grid">
{issueNeedsMediaTitle ? (
<label className="issue-field-span-2">
<span>Affected movie or TV show</span>
<input
required
value={issueMediaTitle}
onChange={(event) => setIssueMediaTitle(event.target.value)}
placeholder="Start typing the exact title"
/>
</label>
) : null}
{issueNeedsMediaTitle ? (
<label>
<span>Media type</span>
<select
value={issueMediaType}
onChange={(event) => setIssueMediaType(event.target.value as 'movie' | 'tv')}
>
<option value="movie">Movie</option>
<option value="tv">TV show</option>
</select>
</label>
) : null}
{issueNeedsMediaTitle && issueMediaType === 'tv' ? (
<label>
<span>Season / episode</span>
<input
value={issueEpisode}
onChange={(event) => setIssueEpisode(event.target.value)}
placeholder="For example S02 E04"
/>
</label>
) : null}
<label className={issueNeedsMediaTitle && issueMediaType === 'tv' ? 'issue-field-span-2' : ''}>
<span>What happens?</span>
<select value={issueSymptom} onChange={(event) => setIssueSymptom(event.target.value)}>
{ISSUE_SYMPTOMS[issueCategory].map((symptom) => (
<option key={symptom} value={symptom}>{symptom}</option>
))}
</select>
</label>
{(issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? (
<label>
<span>Device or app</span>
<input
value={issueDevice}
onChange={(event) => setIssueDevice(event.target.value)}
placeholder="For example Samsung TV or Chrome"
/>
</label>
) : null}
<label className="issue-field-span-2">
<span>Anything else we should know?</span>
<textarea
rows={3}
value={issueNotes}
onChange={(event) => setIssueNotes(event.target.value)}
placeholder="Optional error message, timestamp, language, edition, or anything unusual"
/>
</label>
</div>
{issueNeedsServerCheck ? (
<section className={`media-status-check media-status-${mediaServerStatus?.status ?? 'checking'}`}>
<div className="media-status-heading">
<div>
<span className="section-kicker">Live media-server check</span>
<h3>
{mediaServerChecking
? 'Checking Jellyfin now...'
: mediaServerStatus?.headline ?? 'Server check unavailable'}
</h3>
</div>
<button type="button" className="ghost-button" onClick={() => void checkMediaServer()} disabled={mediaServerChecking}>
{mediaServerChecking ? 'Checking...' : 'Check again'}
</button>
</div>
{mediaServerChecking ? <div className="issue-live-scan"><i /><span>Contacting the media server</span></div> : null}
{mediaServerError ? <div className="error-banner">{mediaServerError}</div> : null}
{mediaServerStatus ? (
<>
<p>{mediaServerStatus.message}</p>
<div className="media-status-metrics">
<div><span>Server API</span><strong>{mediaServerStatus.status === 'down' ? 'Unavailable' : 'Responding'}</strong></div>
<div><span>Response</span><strong>{mediaServerStatus.latency_ms ?? '--'} ms</strong></div>
<div><span>Active streams</span><strong>{mediaServerStatus.activity?.available ? mediaServerStatus.activity.active_streams ?? 0 : '--'}</strong></div>
<div><span>Transcoding</span><strong>{mediaServerStatus.activity?.available ? mediaServerStatus.activity.transcoding_streams ?? 0 : '--'}</strong></div>
</div>
</>
) : null}
</section>
) : null}
<section className="issue-resolution-card">
<span className="issue-step-number">03</span>
<div>
<span className="section-kicker">Recommended path</span>
<h2>{selectedIssueDefinition.outcome.replace('Likely action: ', '')}</h2>
<p>
{issueNeedsServerCheck
? 'The live check above will be saved in the report, giving administrators immediate context.'
: 'Submit this report and it will arrive with the replacement or collection path already identified.'}
</p>
</div>
<button type="submit" disabled={creating || mediaServerChecking}>
{creating ? 'Submitting...' : 'Submit issue'}
</button>
</section>
</form>
) : null}
</section>
)}
{workspace === 'request' ? (
<>
<section className="portal-overview-grid">
<div className="portal-overview-card">
<span>Total {workspace === 'request' ? 'requests' : 'issues'}</span>
@@ -908,6 +1359,18 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
</form>
</section>
</>
) : null}
{workspace === 'issue' ? (
<div className="issue-history-heading">
<div>
<span className="section-kicker">Issue history</span>
<h2>Reported problems</h2>
</div>
<span>{totalItems} total</span>
</div>
) : null}
<section className="portal-toolbar">
<label>
@@ -970,7 +1433,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<div className="portal-item-row-main">
<div className="portal-item-row-title">
<strong>{item.title}</strong>
<span className="small-pill">{item.kind}</span>
<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>
@@ -1013,6 +1480,15 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<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:{' '}
@@ -1111,16 +1587,26 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</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>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>