Streamline issue wizard and make selections explicit
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
- Keep technical IDs and pipeline labels monospace. Use sentence case for ordinary labels and descriptions.
|
||||
- Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens.
|
||||
- Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both.
|
||||
- The guided issue form uses `portal/IssueFlowStep.tsx`: show one expanded step, collapse completed answers into Change rows, and keep repairs behind the final submit action. Movies use the selected title's managed file directly; only TV needs a season/episode picker. Multi-select controls must expose `aria-pressed` and a visible selected state.
|
||||
|
||||
## Browser checks
|
||||
|
||||
@@ -15,6 +16,7 @@ Build the frontend before reviewing. The scripts in `scripts/` run using Node an
|
||||
|
||||
- `review_layout_ui.cjs`: page alignment, consistent headings, overflow, redirects, pipeline layout, invite tabs, and fixture-only recovery forms.
|
||||
- `review_account_ui.cjs`: fixture-only login and profile interaction checks.
|
||||
- `review_issue_flow_ui.cjs`: fixture-only movie/TV issue flow, multi-device report payloads, subtitle routing, permissions, and collapsed-step navigation.
|
||||
- `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement.
|
||||
|
||||
The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository.
|
||||
|
||||
@@ -3,6 +3,7 @@ import './ops-redesign.css'
|
||||
import './admin/config.css'
|
||||
import './account.css'
|
||||
import './workspace.css'
|
||||
import './portal/issue-flow.css'
|
||||
import type { ReactNode } from 'react'
|
||||
import BrandingFavicon from './ui/BrandingFavicon'
|
||||
import ApplicationChrome from './ui/ApplicationChrome'
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
|
||||
export default function IssueFlowStep({
|
||||
number, title, summary, active, complete, onEdit, children,
|
||||
}: {
|
||||
number: number
|
||||
title: string
|
||||
summary: string
|
||||
active: boolean
|
||||
complete: boolean
|
||||
onEdit: () => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const heading = useRef<HTMLHeadingElement>(null)
|
||||
useEffect(() => {
|
||||
if (!active || number === 1) return
|
||||
heading.current?.focus({ preventScroll: true })
|
||||
heading.current?.scrollIntoView({ block: 'nearest', behavior: 'instant' })
|
||||
}, [active, number])
|
||||
|
||||
if (!active && !complete) return null
|
||||
return (
|
||||
<section className={`issue-procedure-step ${active ? 'is-current' : 'is-complete'}`} aria-label={title}>
|
||||
{active ? (
|
||||
<>
|
||||
<div className="issue-flow-heading">
|
||||
<span className="issue-step-number" aria-hidden="true">{String(number).padStart(2, '0')}</span>
|
||||
<h2 ref={heading} tabIndex={-1}>{title}</h2>
|
||||
</div>
|
||||
<div className="issue-procedure-content">{children}</div>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="issue-step-summary" onClick={onEdit} aria-label={`Change ${title}: ${summary}`}>
|
||||
<span className="issue-step-number" aria-hidden="true">✓</span>
|
||||
<span className="issue-step-summary-copy"><small>{title}</small><strong>{summary}</strong></span>
|
||||
<span className="issue-step-change">Change</span>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* One expanded procedure at a time; completed steps become editable summaries. */
|
||||
.issue-flow-progressive .issue-guided-form { padding: 0; border: 0; }
|
||||
.issue-wizard-fields { display: grid; gap: 10px; min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.issue-procedure-step { min-width: 0; border-bottom: 1px solid var(--ops-line-soft); }
|
||||
.issue-procedure-step:last-child { border-bottom: 0; }
|
||||
.issue-procedure-step.is-current { padding: 16px 0 8px; }
|
||||
.issue-procedure-step .issue-flow-heading { align-items: center; margin-bottom: 18px; }
|
||||
.issue-procedure-step h2 { scroll-margin-top: 130px; }
|
||||
.issue-procedure-content { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-width: 0; animation: issue-step-enter 150ms ease-out; }
|
||||
.issue-flow-progressive .issue-category-card { min-height: 122px; gap: 6px; padding: 14px; }
|
||||
.issue-flow-progressive .issue-media-finder { padding: 0; border: 0; background: transparent; }
|
||||
.page .issue-flow-progressive .issue-step-summary {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center;
|
||||
gap: 12px; width: 100%; padding: 10px 0; border: 0 !important;
|
||||
background: transparent !important; text-align: left; color: var(--ops-text) !important;
|
||||
box-shadow: none; text-transform: none;
|
||||
}
|
||||
.issue-step-summary .issue-step-number { width: 28px; height: 28px; color: var(--ops-primary-2); }
|
||||
.issue-step-summary-copy { display: grid; gap: 3px; min-width: 0; }
|
||||
.issue-step-summary-copy small { color: var(--ops-muted); font-size: 11px; font-weight: 500; }
|
||||
.issue-step-summary-copy strong { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.issue-step-change { color: var(--ops-primary-2); font-size: 12px; }
|
||||
.issue-step-summary:hover .issue-step-change { text-decoration: underline; }
|
||||
.issue-procedure-actions { display: flex; grid-column: 1 / -1; justify-content: flex-end; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.issue-procedure-actions button { min-height: 44px; }
|
||||
.page .issue-procedure-actions > button:not(.ghost-button) {
|
||||
background: #c7bdff !important; border-color: #c7bdff !important; color: #1c172c !important;
|
||||
}
|
||||
.issue-procedure-actions button:disabled { opacity: .4; }
|
||||
.issue-selection-count { margin-right: auto; color: var(--ops-muted); font-size: 12px; }
|
||||
.issue-flow-progressive .issue-choice-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.issue-flow-progressive .issue-choice-row button { display: flex; align-items: center; justify-content: flex-start; gap: 10px; min-height: 48px; }
|
||||
.issue-device-check { display: grid; place-items: center; width: 22px; height: 22px; flex: 0 0 22px; border: 1px solid currentColor; border-radius: 6px; }
|
||||
/* Legacy global button colours are !important; scoped overrides keep toggles visible. */
|
||||
.page .issue-flow-progressive button[aria-pressed='true'] {
|
||||
border-color: #c7bdff !important; background: #373147 !important; color: #f5f0ff !important;
|
||||
box-shadow: inset 0 0 0 1px #c7bdff;
|
||||
}
|
||||
.issue-device-feedback { margin: 4px 0 0; color: var(--ops-muted); font-size: 12px; line-height: 1.5; }
|
||||
.issue-flow-progressive .issue-resolution-card { grid-template-columns: minmax(0, 1fr) auto; padding: 0; border: 0; }
|
||||
.issue-flow-progressive .issue-resolution-card h3 { margin: 0; font-size: 19px; line-height: 1.4; }
|
||||
.issue-flow-progressive .issue-resolution-card p { margin-top: 8px; font-size: 13px; }
|
||||
.issue-flow-progressive .status-banner { display: grid; gap: 10px; }
|
||||
.issue-flow-progressive .status-banner button { justify-self: start; }
|
||||
@keyframes issue-step-enter { from { opacity: .5; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@media (max-width: 680px) {
|
||||
.issue-flow-progressive .issue-category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.issue-flow-progressive .issue-category-card { min-height: 112px; padding: 12px; }
|
||||
.issue-flow-progressive .issue-category-card p { display: none; }
|
||||
.issue-flow-progressive .issue-category-card strong { font-size: 13px; }
|
||||
.issue-procedure-step .issue-flow-heading h2 { font-size: 19px; }
|
||||
.issue-flow-progressive .issue-step-summary { gap: 8px; }
|
||||
.issue-flow-progressive .issue-choice-row button { font-size: 12px; padding: 10px; }
|
||||
.issue-flow-progressive .issue-resolution-card { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .issue-procedure-content { animation: none; } }
|
||||
Reference in New Issue
Block a user