Process 1 build 0803262229

This commit is contained in:
2026-03-08 22:30:49 +13:00
parent f830fc1296
commit 3609f44607
6 changed files with 259 additions and 176 deletions
+1 -1
View File
@@ -1 +1 @@
0803262216 0803262229
File diff suppressed because one or more lines are too long
+22 -1
View File
@@ -6565,6 +6565,27 @@ textarea {
gap: 16px; gap: 16px;
} }
.portal-workspace-switch {
display: inline-flex;
gap: 8px;
align-items: center;
}
.portal-workspace-switch button {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-soft);
color: var(--text);
padding: 8px 12px;
font-weight: 600;
}
.portal-workspace-switch button.is-active {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(107, 146, 255, 0.25);
background: rgba(107, 146, 255, 0.12);
}
.portal-overview-grid { .portal-overview-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -6689,7 +6710,7 @@ textarea {
.portal-toolbar { .portal-toolbar {
display: grid; display: grid;
grid-template-columns: 160px 180px minmax(0, 1fr) auto; grid-template-columns: 180px minmax(0, 1fr) auto;
gap: 10px; gap: 10px;
align-items: end; align-items: end;
} }
+231 -169
View File
@@ -80,12 +80,6 @@ type DiscoveryResult = {
backdropPath?: string | null backdropPath?: string | null
} }
const KIND_OPTIONS = [
{ value: 'request', label: 'Request' },
{ value: 'issue', label: 'Issue' },
{ value: 'feature', label: 'Feature' },
] as const
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ value: 'new', label: 'New' }, { value: 'new', label: 'New' },
{ value: 'triaging', label: 'Triaging' }, { value: 'triaging', label: 'Triaging' },
@@ -131,6 +125,31 @@ const MEDIA_TYPE_OPTIONS = [
{ value: 'tv', label: 'TV' }, { value: 'tv', label: 'TV' },
] as const ] as const
const WORKSPACE_OPTIONS = [
{ value: 'request', label: 'Requests' },
{ value: 'issue', label: 'Issues' },
] as const
const REQUEST_FILTER_STATUS_OPTIONS = [
{ value: 'pending', label: 'Pending approval' },
{ value: 'approved', label: 'Approved' },
{ value: 'processing', label: 'Processing' },
{ value: 'partially_available', label: 'Partially available' },
{ value: 'available', label: 'Available' },
{ value: 'failed', label: 'Failed' },
{ value: 'declined', label: 'Declined' },
] as const
const ISSUE_FILTER_STATUS_OPTIONS = [
{ value: 'new', label: 'New' },
{ value: 'triaging', label: 'Triaging' },
{ value: 'planned', label: 'Planned' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'blocked', label: 'Blocked' },
{ value: 'done', label: 'Done' },
{ value: 'closed', label: 'Closed' },
] as const
const formatDate = (value?: string | null) => { const formatDate = (value?: string | null) => {
if (!value) return 'Never' if (!value) return 'Never'
const parsed = new Date(value) const parsed = new Date(value)
@@ -161,13 +180,13 @@ export default function PortalPage() {
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null)
const [totalItems, setTotalItems] = useState(0) const [totalItems, setTotalItems] = useState(0)
const [hasMore, setHasMore] = useState(false) const [hasMore, setHasMore] = useState(false)
const [workspace, setWorkspace] = useState<'request' | 'issue'>('request')
const [filterKind, setFilterKind] = useState('') const [filterKind, setFilterKind] = useState<'request' | 'issue'>('request')
const [filterStatus, setFilterStatus] = useState('') const [filterStatus, setFilterStatus] = useState('')
const [filterMine, setFilterMine] = useState(false) const [filterMine, setFilterMine] = useState(false)
const [filterSearch, setFilterSearch] = useState('') const [filterSearch, setFilterSearch] = useState('')
const [createKind, setCreateKind] = useState<'request' | 'issue' | 'feature'>('request')
const [createTitle, setCreateTitle] = useState('') const [createTitle, setCreateTitle] = useState('')
const [createDescription, setCreateDescription] = useState('') const [createDescription, setCreateDescription] = useState('')
const [createMediaType, setCreateMediaType] = useState('') const [createMediaType, setCreateMediaType] = useState('')
@@ -196,6 +215,9 @@ export default function PortalPage() {
const [requestingTmdbIds, setRequestingTmdbIds] = useState<Record<string, boolean>>({}) const [requestingTmdbIds, setRequestingTmdbIds] = useState<Record<string, boolean>>({})
const isAdmin = me?.role === 'admin' 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'
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
@@ -285,7 +307,7 @@ export default function PortalPage() {
limit: '60', limit: '60',
offset: '0', offset: '0',
}) })
if (filterKind) params.set('kind', filterKind) params.set('kind', filterKind)
if (filterStatus) params.set('status', filterStatus) if (filterStatus) params.set('status', filterStatus)
if (filterMine) params.set('mine', '1') if (filterMine) params.set('mine', '1')
const trimmedSearch = filterSearch.trim() const trimmedSearch = filterSearch.trim()
@@ -468,6 +490,17 @@ export default function PortalPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterKind, filterStatus, filterMine, filterSearch]) }, [filterKind, filterStatus, filterMine, filterSearch])
useEffect(() => {
setFilterKind(workspace)
setFilterStatus('')
setCreateMediaType('')
setCreateYear('')
setSelectedItemId(null)
setSelectedItem(null)
setComments([])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspace])
useEffect(() => { useEffect(() => {
if (selectedItemId == null) return if (selectedItemId == null) return
void loadItem(selectedItemId) void loadItem(selectedItemId)
@@ -495,11 +528,11 @@ export default function PortalPage() {
setStatus(null) setStatus(null)
try { try {
const payload: Record<string, any> = { const payload: Record<string, any> = {
kind: createKind, kind: workspace,
title: createTitle, title: createTitle,
description: createDescription, description: createDescription,
media_type: createMediaType || null, media_type: workspace === 'request' ? createMediaType || null : null,
year: createYear.trim() ? toPositiveInt(createYear) : null, year: workspace === 'request' && createYear.trim() ? toPositiveInt(createYear) : null,
external_ref: createExternalRef || null, external_ref: createExternalRef || null,
priority: createPriority, priority: createPriority,
} }
@@ -520,7 +553,7 @@ export default function PortalPage() {
} }
const data = await response.json() const data = await response.json()
const item = data?.item as PortalItem | undefined const item = data?.item as PortalItem | undefined
setStatus('Portal item created.') setStatus(workspace === 'request' ? 'Request item created.' : 'Issue item created.')
setCreateTitle('') setCreateTitle('')
setCreateDescription('') setCreateDescription('')
setCreateMediaType('') setCreateMediaType('')
@@ -649,97 +682,118 @@ export default function PortalPage() {
<div> <div>
<h1>Request portal</h1> <h1>Request portal</h1>
<p className="lede"> <p className="lede">
Raise requests, issues, and feature ideas. Track progress and keep discussion in one place. Manage requests and issues in separate workflows.
</p> </p>
</div> </div>
</div> </div>
<section className="portal-workspace-switch">
{WORKSPACE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={workspace === option.value ? 'is-active' : ''}
onClick={() => setWorkspace(option.value)}
>
{option.label}
</button>
))}
</section>
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>} {status && <div className="status-banner">{status}</div>}
<section className="admin-panel portal-discovery-panel"> {workspace === 'request' ? (
<div className="user-directory-panel-header"> <section className="admin-panel portal-discovery-panel">
<div> <div className="user-directory-panel-header">
<h2>Search and request content</h2> <div>
<p className="lede"> <h2>Search and request content</h2>
Search Seerr content directly, then submit a request in one click. <p className="lede">
</p> Search Seerr content directly, then submit a request in one click.
</p>
</div>
</div> </div>
</div> <form className="portal-discovery-form" onSubmit={runDiscoverySearch}>
<form className="portal-discovery-form" onSubmit={runDiscoverySearch}> <input
<input value={discoverQuery}
value={discoverQuery} onChange={(event) => setDiscoverQuery(event.target.value)}
onChange={(event) => setDiscoverQuery(event.target.value)} placeholder="Search movies or TV shows"
placeholder="Search movies or TV shows" />
/> <button type="submit" disabled={discoverLoading}>
<button type="submit" disabled={discoverLoading}> {discoverLoading ? 'Searching…' : 'Search'}
{discoverLoading ? 'Searching…' : 'Search'} </button>
</button> </form>
</form> {discoverError && <div className="error-banner">{discoverError}</div>}
{discoverError && <div className="error-banner">{discoverError}</div>} <div className="portal-discovery-results">
<div className="portal-discovery-results"> {discoverLoading ? (
{discoverLoading ? ( <div className="status-banner">Searching Seerr</div>
<div className="status-banner">Searching Seerr</div> ) : discoverResults.length === 0 ? (
) : discoverResults.length === 0 ? ( <div className="status-banner">No discovery results yet.</div>
<div className="status-banner">No discovery results yet.</div> ) : (
) : ( discoverResults.map((item, index) => {
discoverResults.map((item, index) => { const key = `${item.type ?? 'unknown'}:${item.tmdbId ?? index}`
const key = `${item.type ?? 'unknown'}:${item.tmdbId ?? index}` const requesting = Boolean(requestingTmdbIds[key])
const requesting = Boolean(requestingTmdbIds[key]) const poster = resolveTmdbArtworkUrl(item.posterPath, 'w185')
const poster = resolveTmdbArtworkUrl(item.posterPath, 'w185') const hasRequest = typeof item.requestId === 'number' && item.requestId > 0
const hasRequest = typeof item.requestId === 'number' && item.requestId > 0 return (
return ( <div key={key} className="portal-discovery-item">
<div key={key} className="portal-discovery-item"> <div className="portal-discovery-media">
<div className="portal-discovery-media"> {poster ? <img src={poster} alt="" loading="lazy" /> : <div className="poster-fallback">No artwork</div>}
{poster ? <img src={poster} alt="" loading="lazy" /> : <div className="poster-fallback">No artwork</div>}
</div>
<div className="portal-discovery-main">
<div className="portal-discovery-title-row">
<strong>{item.title || 'Untitled'}</strong>
<span className="small-pill">{item.type ?? 'unknown'}</span>
{item.year ? <span className="small-pill is-muted">{item.year}</span> : null}
</div> </div>
<p> <div className="portal-discovery-main">
<div className="portal-discovery-title-row">
<strong>{item.title || 'Untitled'}</strong>
<span className="small-pill">{item.type ?? 'unknown'}</span>
{item.year ? <span className="small-pill is-muted">{item.year}</span> : null}
</div>
<p>
{hasRequest ? (
<>
Already requested
{item.statusLabel ? ` · ${item.statusLabel}` : ''}
{item.requestId ? ` · #${item.requestId}` : ''}
</>
) : (
'Not requested yet'
)}
</p>
</div>
<div className="portal-discovery-actions">
{hasRequest ? ( {hasRequest ? (
<> <button
Already requested type="button"
{item.statusLabel ? ` · ${item.statusLabel}` : ''} onClick={() => router.push(`/requests/${item.requestId}`)}
{item.requestId ? ` · #${item.requestId}` : ''} >
</> Open request
</button>
) : ( ) : (
'Not requested yet' <button
type="button"
onClick={() => void requestDiscoveryItem(item)}
disabled={requesting || !item.tmdbId || !item.type}
>
{requesting ? 'Requesting…' : 'Request'}
</button>
)} )}
</p> </div>
</div> </div>
<div className="portal-discovery-actions"> )
{hasRequest ? ( })
<button )}
type="button" </div>
onClick={() => router.push(`/requests/${item.requestId}`)} </section>
> ) : (
Open request <section className="admin-panel">
</button> <div className="status-banner">
) : ( Issue workspace is for reporting problems and tracking resolution separately from content requests.
<button </div>
type="button" </section>
onClick={() => void requestDiscoveryItem(item)} )}
disabled={requesting || !item.tmdbId || !item.type}
>
{requesting ? 'Requesting…' : 'Request'}
</button>
)}
</div>
</div>
)
})
)}
</div>
</section>
<section className="portal-overview-grid"> <section className="portal-overview-grid">
<div className="portal-overview-card"> <div className="portal-overview-card">
<span>Total items</span> <span>Total {workspace === 'request' ? 'requests' : 'issues'}</span>
<strong>{Number(overview?.overview?.total_items ?? totalItems ?? 0)}</strong> <strong>{visibleKindCount}</strong>
</div> </div>
<div className="portal-overview-card"> <div className="portal-overview-card">
<span>Total comments</span> <span>Total comments</span>
@@ -756,26 +810,13 @@ export default function PortalPage() {
</section> </section>
<section className="admin-panel portal-create-panel"> <section className="admin-panel portal-create-panel">
<h2>Create item</h2> <h2>{workspace === 'request' ? 'Create request item' : 'Create issue item'}</h2>
<p className="lede"> <p className="lede">
Use <strong>Request</strong> for new content, <strong>Issue</strong> for broken behavior, and <strong>Feature</strong> for improvements. {workspace === 'request'
? 'Create and track request-related notes in a dedicated request workflow.'
: 'Create and track operational issues in a dedicated issue workflow.'}
</p> </p>
<form onSubmit={createItem} className="admin-form compact-form portal-form-grid"> <form onSubmit={createItem} className="admin-form compact-form portal-form-grid">
<label>
<span>Type</span>
<select
value={createKind}
onChange={(event) =>
setCreateKind(event.target.value as 'request' | 'issue' | 'feature')
}
>
{KIND_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label> <label>
<span>Priority</span> <span>Priority</span>
<select <select
@@ -797,7 +838,11 @@ export default function PortalPage() {
required required
value={createTitle} value={createTitle}
onChange={(event) => setCreateTitle(event.target.value)} onChange={(event) => setCreateTitle(event.target.value)}
placeholder="Short summary of the request or issue" placeholder={
workspace === 'request'
? 'Short summary of the request item'
: 'Short summary of the issue'
}
/> />
</label> </label>
<label className="portal-field-span-2"> <label className="portal-field-span-2">
@@ -807,28 +852,36 @@ export default function PortalPage() {
rows={4} rows={4}
value={createDescription} value={createDescription}
onChange={(event) => setCreateDescription(event.target.value)} onChange={(event) => setCreateDescription(event.target.value)}
placeholder="Add details, expected behavior, and any context." placeholder={
/> workspace === 'request'
</label> ? 'Add request context, expected media, and notes.'
<label> : 'Describe the issue, impact, and expected behavior.'
<span>Media type</span> }
<select value={createMediaType} onChange={(event) => setCreateMediaType(event.target.value)}>
{MEDIA_TYPE_OPTIONS.map((option) => (
<option key={option.value || 'none'} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
<span>Year</span>
<input
value={createYear}
onChange={(event) => setCreateYear(event.target.value)}
inputMode="numeric"
placeholder="Optional"
/> />
</label> </label>
{workspace === 'request' && (
<>
<label>
<span>Media type</span>
<select value={createMediaType} onChange={(event) => setCreateMediaType(event.target.value)}>
{MEDIA_TYPE_OPTIONS.map((option) => (
<option key={option.value || 'none'} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
<span>Year</span>
<input
value={createYear}
onChange={(event) => setCreateYear(event.target.value)}
inputMode="numeric"
placeholder="Optional"
/>
</label>
</>
)}
<label className="portal-field-span-2"> <label className="portal-field-span-2">
<span>External reference</span> <span>External reference</span>
<input <input
@@ -839,29 +892,22 @@ export default function PortalPage() {
</label> </label>
<div className="admin-inline-actions portal-field-span-2"> <div className="admin-inline-actions portal-field-span-2">
<button type="submit" disabled={creating}> <button type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create portal item'} {creating
? 'Creating…'
: workspace === 'request'
? 'Create request item'
: 'Create issue item'}
</button> </button>
</div> </div>
</form> </form>
</section> </section>
<section className="portal-toolbar"> <section className="portal-toolbar">
<label>
<span>Type</span>
<select value={filterKind} onChange={(event) => setFilterKind(event.target.value)}>
<option value="">All</option>
{KIND_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label> <label>
<span>Status</span> <span>Status</span>
<select value={filterStatus} onChange={(event) => setFilterStatus(event.target.value)}> <select value={filterStatus} onChange={(event) => setFilterStatus(event.target.value)}>
<option value="">All</option> <option value="">All</option>
{STATUS_OPTIONS.map((option) => ( {(workspace === 'request' ? REQUEST_FILTER_STATUS_OPTIONS : ISSUE_FILTER_STATUS_OPTIONS).map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.label}
</option> </option>
@@ -873,7 +919,11 @@ export default function PortalPage() {
<input <input
value={filterSearch} value={filterSearch}
onChange={(event) => setFilterSearch(event.target.value)} onChange={(event) => setFilterSearch(event.target.value)}
placeholder="Title, description, or item id" placeholder={
workspace === 'request'
? 'Search request items by title, description, or id'
: 'Search issue items by title, description, or id'
}
/> />
</label> </label>
<label className="inline-checkbox portal-mine-toggle"> <label className="inline-checkbox portal-mine-toggle">
@@ -890,15 +940,17 @@ export default function PortalPage() {
<section className="admin-panel portal-list-panel"> <section className="admin-panel portal-list-panel">
<div className="user-directory-panel-header"> <div className="user-directory-panel-header">
<div> <div>
<h2>Items</h2> <h2>{workspace === 'request' ? 'Requests' : 'Issues'}</h2>
<p className="lede"> <p className="lede">
{totalItems} total {totalItems} total {workspaceLabelPlural}
{hasMore ? ' (showing first 60)' : ''} {hasMore ? ' (showing first 60)' : ''}
</p> </p>
</div> </div>
</div> </div>
{items.length === 0 ? ( {items.length === 0 ? (
<div className="status-banner">No portal items match this filter.</div> <div className="status-banner">
No {workspaceLabelPlural} match this filter.
</div>
) : ( ) : (
<div className="portal-item-list"> <div className="portal-item-list">
{items.map((item) => ( {items.map((item) => (
@@ -935,16 +987,22 @@ export default function PortalPage() {
<section className="admin-panel portal-detail-panel"> <section className="admin-panel portal-detail-panel">
{!selectedItemId ? ( {!selectedItemId ? (
<div className="status-banner">Select an item to view details.</div> <div className="status-banner">
Select a {workspaceLabel} to view details.
</div>
) : loadingItem ? ( ) : loadingItem ? (
<div className="status-banner">Loading details</div> <div className="status-banner">Loading details</div>
) : !selectedItem ? ( ) : !selectedItem ? (
<div className="status-banner">Item not found.</div> <div className="status-banner">
{workspace === 'request' ? 'Request' : 'Issue'} not found.
</div>
) : ( ) : (
<> <>
<div className="user-directory-panel-header"> <div className="user-directory-panel-header">
<div> <div>
<h2>Item #{selectedItem.id}</h2> <h2>
{selectedItem.kind === 'request' ? 'Request' : 'Issue'} #{selectedItem.id}
</h2>
<p className="lede"> <p className="lede">
Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)} Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)}
</p> </p>
@@ -979,29 +1037,33 @@ export default function PortalPage() {
disabled={!selectedItem.permissions?.can_edit} disabled={!selectedItem.permissions?.can_edit}
/> />
</label> </label>
<label> {selectedItem.kind === 'request' ? (
<span>Media type</span> <>
<select <label>
value={editMediaType} <span>Media type</span>
onChange={(event) => setEditMediaType(event.target.value)} <select
disabled={!selectedItem.permissions?.can_edit} value={editMediaType}
> onChange={(event) => setEditMediaType(event.target.value)}
{MEDIA_TYPE_OPTIONS.map((option) => ( disabled={!selectedItem.permissions?.can_edit}
<option key={option.value || 'none'} value={option.value}> >
{option.label} {MEDIA_TYPE_OPTIONS.map((option) => (
</option> <option key={option.value || 'none'} value={option.value}>
))} {option.label}
</select> </option>
</label> ))}
<label> </select>
<span>Year</span> </label>
<input <label>
value={editYear} <span>Year</span>
onChange={(event) => setEditYear(event.target.value)} <input
inputMode="numeric" value={editYear}
disabled={!selectedItem.permissions?.can_edit} onChange={(event) => setEditYear(event.target.value)}
/> inputMode="numeric"
</label> disabled={!selectedItem.permissions?.can_edit}
/>
</label>
</>
) : null}
<label className="portal-field-span-2"> <label className="portal-field-span-2">
<span>External reference</span> <span>External reference</span>
<input <input
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "magent-frontend", "name": "magent-frontend",
"version": "0803262216", "version": "0803262229",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "magent-frontend", "name": "magent-frontend",
"version": "0803262216", "version": "0803262229",
"dependencies": { "dependencies": {
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.4", "react": "19.2.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "magent-frontend", "name": "magent-frontend",
"private": true, "private": true,
"version": "0803262216", "version": "0803262229",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",