From d9ac54a2ffbb3f4f187a2a209f24d618dca11848 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Sun, 8 Mar 2026 22:38:31 +1300 Subject: [PATCH] Process 1 build 0803262237 --- .build_number | 2 +- backend/app/build_info.py | 4 +- frontend/app/portal/PortalClient.tsx | 1210 +++++++++++++++++++++++++ frontend/app/portal/issues/page.tsx | 6 + frontend/app/portal/page.tsx | 1203 +----------------------- frontend/app/portal/requests/page.tsx | 6 + frontend/package-lock.json | 4 +- frontend/package.json | 2 +- 8 files changed, 1231 insertions(+), 1206 deletions(-) create mode 100644 frontend/app/portal/PortalClient.tsx create mode 100644 frontend/app/portal/issues/page.tsx create mode 100644 frontend/app/portal/requests/page.tsx diff --git a/.build_number b/.build_number index 1abf701..2862b8b 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -0803262229 +0803262237 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index d7c70d3..5fc2598 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "0803262229" -CHANGELOG = '2026-03-08|Process 1 build 0803262216\n2026-03-08|Process 1 build 0803262038\n2026-03-07|Process 1 build 0703261729\n2026-03-04|Process 1 build 0403261902\n2026-03-04|Improve email deliverability headers and SMTP identity\n2026-03-04|Fix admin user email visibility\n2026-03-04|Harden auth flows and add backend quality gate\n2026-03-03|Fix email branding with inline logo and reliable MIME transport\n2026-03-03|Fix email template rendering for Outlook-safe branded content\n2026-03-03|Update all email templates with uniform branded graphics\n2026-03-03|Add branded HTML email templates\n2026-03-03|Add SMTP receipt logging for Exchange relay tracing\n2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' +BUILD_NUMBER = "0803262237" +CHANGELOG = '2026-03-08|Process 1 build 0803262229\n2026-03-08|Process 1 build 0803262216\n2026-03-08|Process 1 build 0803262038\n2026-03-07|Process 1 build 0703261729\n2026-03-04|Process 1 build 0403261902\n2026-03-04|Improve email deliverability headers and SMTP identity\n2026-03-04|Fix admin user email visibility\n2026-03-04|Harden auth flows and add backend quality gate\n2026-03-03|Fix email branding with inline logo and reliable MIME transport\n2026-03-03|Fix email template rendering for Outlook-safe branded content\n2026-03-03|Update all email templates with uniform branded graphics\n2026-03-03|Add branded HTML email templates\n2026-03-03|Add SMTP receipt logging for Exchange relay tracing\n2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' diff --git a/frontend/app/portal/PortalClient.tsx b/frontend/app/portal/PortalClient.tsx new file mode 100644 index 0000000..8a81527 --- /dev/null +++ b/frontend/app/portal/PortalClient.tsx @@ -0,0 +1,1210 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' + +type PortalPermissions = { + can_edit?: boolean + can_comment?: boolean + can_moderate?: boolean +} + +type PortalItem = { + id: number + kind: 'request' | 'issue' | 'feature' + title: string + description: string + media_type?: 'movie' | 'tv' | null + year?: number | null + external_ref?: string | null + source_system?: string | null + source_request_id?: number | null + status: string + priority: string + created_by_username: string + assignee_username?: string | null + created_at: string + updated_at: string + last_activity_at: string + permissions?: PortalPermissions + workflow?: { + request_status?: string + media_status?: string + stage_label?: string + is_terminal?: boolean + } + issue?: { + issue_type?: string + related_item_id?: number | null + is_resolved?: boolean + resolved_at?: string | null + } +} + +type PortalComment = { + id: number + item_id: number + author_username: string + author_role: string + message: string + is_internal: boolean + created_at: string +} + +type PortalOverview = { + overview?: { + total_items?: number + total_comments?: number + by_kind?: Record + by_status?: Record + } + my_items?: number +} + +type UserProfile = { + username: string + role: string +} + +type DiscoveryResult = { + title: string + year?: number | null + type?: 'movie' | 'tv' | null + tmdbId?: number | null + requestId?: number | null + statusLabel?: string | null + status?: number | null + accessible?: boolean + posterPath?: string | null + backdropPath?: string | null +} + +const STATUS_OPTIONS = [ + { value: 'new', label: 'New' }, + { value: 'triaging', label: 'Triaging' }, + { value: 'planned', label: 'Planned' }, + { value: 'in_progress', label: 'In progress' }, + { value: 'blocked', label: 'Blocked' }, + { value: 'done', label: 'Done' }, + { value: 'pending', label: 'Pending approval' }, + { value: 'approved', label: 'Approved' }, + { value: 'processing', label: 'Processing' }, + { value: 'partially_available', label: 'Partially available' }, + { value: 'available', label: 'Available' }, + { value: 'failed', label: 'Failed' }, + { value: 'declined', label: 'Declined' }, + { value: 'closed', label: 'Closed' }, +] as const + +const REQUEST_STATUS_OPTIONS = [ + { value: 'pending', label: 'Pending approval' }, + { value: 'approved', label: 'Approved' }, + { value: 'declined', label: 'Declined' }, +] as const + +const MEDIA_STATUS_OPTIONS = [ + { value: 'pending', label: 'Pending' }, + { value: 'processing', label: 'Processing' }, + { value: 'partially_available', label: 'Partially available' }, + { value: 'available', label: 'Available' }, + { value: 'failed', label: 'Failed' }, + { value: 'unknown', label: 'Unknown' }, +] as const + +const PRIORITY_OPTIONS = [ + { value: 'low', label: 'Low' }, + { value: 'normal', label: 'Normal' }, + { value: 'high', label: 'High' }, + { value: 'urgent', label: 'Urgent' }, +] as const + +const MEDIA_TYPE_OPTIONS = [ + { value: '', label: 'None' }, + { value: 'movie', label: 'Movie' }, + { value: 'tv', label: 'TV' }, +] as const + +const REQUEST_FILTER_STATUS_OPTIONS = [ + { value: 'pending', label: 'Pending approval' }, + { value: 'approved', label: 'Approved' }, + { value: 'processing', label: 'Processing' }, + { value: 'partially_available', label: 'Partially available' }, + { value: 'available', label: 'Available' }, + { value: 'failed', label: 'Failed' }, + { value: 'declined', label: 'Declined' }, +] as const + +const ISSUE_FILTER_STATUS_OPTIONS = [ + { value: 'new', label: 'New' }, + { value: 'triaging', label: 'Triaging' }, + { value: 'planned', label: 'Planned' }, + { value: 'in_progress', label: 'In progress' }, + { value: 'blocked', label: 'Blocked' }, + { value: 'done', label: 'Done' }, + { value: 'closed', label: 'Closed' }, +] as const + +const formatDate = (value?: string | null) => { + if (!value) return 'Never' + const parsed = new Date(value) + if (Number.isNaN(parsed.valueOf())) return value + return parsed.toLocaleString() +} + +const toPositiveInt = (value: string) => { + const parsed = Number.parseInt(value, 10) + if (Number.isNaN(parsed) || parsed <= 0) return null + return parsed +} + +type PortalWorkspace = 'request' | 'issue' + +type PortalClientProps = { + workspace: PortalWorkspace +} + +export default function PortalClient({ workspace }: PortalClientProps) { + const router = useRouter() + const [me, setMe] = useState(null) + const [overview, setOverview] = useState(null) + const [items, setItems] = useState([]) + const [selectedItemId, setSelectedItemId] = useState(null) + const [selectedItem, setSelectedItem] = useState(null) + const [comments, setComments] = useState([]) + const [loadingItems, setLoadingItems] = useState(true) + const [loadingItem, setLoadingItem] = useState(false) + const [creating, setCreating] = useState(false) + const [saving, setSaving] = useState(false) + const [commenting, setCommenting] = useState(false) + const [error, setError] = useState(null) + const [status, setStatus] = useState(null) + const [totalItems, setTotalItems] = useState(0) + const [hasMore, setHasMore] = useState(false) + + const filterKind = workspace + const [filterStatus, setFilterStatus] = useState('') + const [filterMine, setFilterMine] = useState(false) + const [filterSearch, setFilterSearch] = useState('') + + const [createTitle, setCreateTitle] = useState('') + const [createDescription, setCreateDescription] = useState('') + const [createMediaType, setCreateMediaType] = useState('') + const [createYear, setCreateYear] = useState('') + const [createExternalRef, setCreateExternalRef] = useState('') + const [createPriority, setCreatePriority] = useState<'low' | 'normal' | 'high' | 'urgent'>('normal') + + const [editTitle, setEditTitle] = useState('') + const [editDescription, setEditDescription] = useState('') + const [editMediaType, setEditMediaType] = useState('') + const [editYear, setEditYear] = useState('') + const [editExternalRef, setEditExternalRef] = useState('') + const [editStatus, setEditStatus] = useState('new') + const [editRequestStatus, setEditRequestStatus] = useState('pending') + const [editMediaStatus, setEditMediaStatus] = useState('pending') + const [editPriority, setEditPriority] = useState('normal') + const [editAssignee, setEditAssignee] = useState('') + + const [commentText, setCommentText] = useState('') + const [commentInternal, setCommentInternal] = useState(false) + const [preselectedItemId, setPreselectedItemId] = useState(null) + const [discoverQuery, setDiscoverQuery] = useState('') + const [discoverLoading, setDiscoverLoading] = useState(false) + const [discoverResults, setDiscoverResults] = useState([]) + const [discoverError, setDiscoverError] = useState(null) + const [requestingTmdbIds, setRequestingTmdbIds] = useState>({}) + + 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(() => { + if (typeof window === 'undefined') return + const raw = new URLSearchParams(window.location.search).get('item') + if (!raw) { + setPreselectedItemId(null) + return + } + const parsed = Number.parseInt(raw, 10) + setPreselectedItemId(Number.isNaN(parsed) || parsed <= 0 ? null : parsed) + }, []) + + const loadMe = async () => { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/auth/me`) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return null + } + throw new Error(`Failed to load session (${response.status})`) + } + const data = await response.json() + const profile: UserProfile = { + username: data?.username ?? 'unknown', + role: data?.role ?? 'user', + } + setMe(profile) + return profile + } + + const loadOverview = async () => { + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/portal/overview`) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + throw new Error(`Failed to load portal overview (${response.status})`) + } + const data = await response.json() + setOverview(data) + } catch (err) { + console.error(err) + } + } + + const loadItem = async (itemId: number) => { + setLoadingItem(true) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/portal/items/${itemId}`) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + if (response.status === 404) { + setSelectedItem(null) + setComments([]) + return + } + throw new Error(`Failed to load portal item (${response.status})`) + } + const data = await response.json() + const item = (data?.item ?? null) as PortalItem | null + setSelectedItem(item) + setComments(Array.isArray(data?.comments) ? data.comments : []) + } catch (err) { + console.error(err) + setError('Could not load portal item details.') + } finally { + setLoadingItem(false) + } + } + + const loadItems = async (options?: { preferItemId?: number | null }) => { + setLoadingItems(true) + try { + const baseUrl = getApiBase() + const params = new URLSearchParams({ + limit: '60', + offset: '0', + }) + params.set('kind', filterKind) + if (filterStatus) params.set('status', filterStatus) + if (filterMine) params.set('mine', '1') + const trimmedSearch = filterSearch.trim() + if (trimmedSearch) params.set('search', trimmedSearch) + + const response = await authFetch(`${baseUrl}/portal/items?${params.toString()}`) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + throw new Error(`Failed to load portal items (${response.status})`) + } + const data = await response.json() + const loadedItems = Array.isArray(data?.items) ? (data.items as PortalItem[]) : [] + setItems(loadedItems) + setTotalItems(Number(data?.total ?? loadedItems.length ?? 0)) + setHasMore(Boolean(data?.has_more)) + + const preferred = options?.preferItemId ?? selectedItemId ?? preselectedItemId + if (preferred && loadedItems.some((item) => item.id === preferred)) { + setSelectedItemId(preferred) + } else if (loadedItems.length > 0) { + setSelectedItemId(loadedItems[0].id) + } else { + setSelectedItemId(null) + setSelectedItem(null) + setComments([]) + } + } catch (err) { + console.error(err) + setError('Could not load portal items.') + } finally { + setLoadingItems(false) + } + } + + const resolveTmdbArtworkUrl = (path?: string | null, size: 'w185' | 'w342' = 'w185') => { + if (!path) return null + const normalized = path.startsWith('/') ? path : `/${path}` + return `https://image.tmdb.org/t/p/${size}${normalized}` + } + + const runDiscoverySearch = async (event?: React.FormEvent) => { + if (event) event.preventDefault() + const query = discoverQuery.trim() + if (!query) { + setDiscoverResults([]) + setDiscoverError('Enter a title to search.') + return + } + setDiscoverLoading(true) + setDiscoverError(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(query)}`) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + const text = await response.text() + throw new Error(text || `Search failed (${response.status})`) + } + const data = await response.json() + const mapped: DiscoveryResult[] = Array.isArray(data?.results) + ? data.results.map((item: any) => ({ + title: item?.title ?? 'Untitled', + year: typeof item?.year === 'number' ? item.year : null, + type: item?.type === 'movie' || item?.type === 'tv' ? item.type : null, + tmdbId: typeof item?.tmdbId === 'number' ? item.tmdbId : null, + requestId: typeof item?.requestId === 'number' ? item.requestId : null, + statusLabel: item?.statusLabel ?? null, + status: typeof item?.status === 'number' ? item.status : null, + accessible: Boolean(item?.accessible), + posterPath: item?.posterPath ?? null, + backdropPath: item?.backdropPath ?? null, + })) + : [] + setDiscoverResults(mapped) + } catch (err) { + console.error(err) + setDiscoverResults([]) + setDiscoverError(err instanceof Error ? err.message : 'Search failed.') + } finally { + setDiscoverLoading(false) + } + } + + const requestDiscoveryItem = async (item: DiscoveryResult) => { + if (!item.tmdbId || !item.type) { + setError('Could not request this result because required media details are missing.') + return + } + const key = `${item.type}:${item.tmdbId}` + setRequestingTmdbIds((prev) => ({ ...prev, [key]: true })) + setError(null) + setStatus(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/requests/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mediaType: item.type, + tmdbId: item.tmdbId, + }), + }) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + const text = await response.text() + throw new Error(text || `Request failed (${response.status})`) + } + const data = await response.json() + const requestId = typeof data?.requestId === 'number' ? data.requestId : null + const statusLabel = typeof data?.statusLabel === 'string' ? data.statusLabel : item.statusLabel + const statusCode = typeof data?.statusCode === 'number' ? data.statusCode : item.status + setDiscoverResults((prev) => + prev.map((entry) => + entry.tmdbId === item.tmdbId && entry.type === item.type + ? { + ...entry, + requestId, + statusLabel, + status: statusCode, + accessible: true, + } + : entry + ) + ) + if (requestId) { + const mode = data?.status === 'exists' ? 'already exists' : 'created' + setStatus(`Request ${mode}. Open request #${requestId} for the full pipeline.`) + } else { + setStatus('Request submitted.') + } + await Promise.all([loadItems(), loadOverview()]) + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not create request.') + } finally { + setRequestingTmdbIds((prev) => { + const next = { ...prev } + delete next[key] + return next + }) + } + } + + useEffect(() => { + if (!getToken()) { + router.push('/login') + return + } + const bootstrap = async () => { + try { + setError(null) + await loadMe() + await Promise.all([loadOverview(), loadItems({ preferItemId: preselectedItemId })]) + } catch (err) { + console.error(err) + setError('Could not load request portal.') + } + } + void bootstrap() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router]) + + useEffect(() => { + if (!getToken()) { + return + } + void loadItems({ preferItemId: preselectedItemId }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterStatus, filterMine, filterSearch, workspace]) + + useEffect(() => { + setFilterStatus('') + setCreateMediaType('') + setCreateYear('') + setSelectedItemId(null) + setSelectedItem(null) + setComments([]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspace]) + + useEffect(() => { + if (selectedItemId == null) return + void loadItem(selectedItemId) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedItemId]) + + useEffect(() => { + if (!selectedItem) return + setEditTitle(selectedItem.title ?? '') + setEditDescription(selectedItem.description ?? '') + setEditMediaType(selectedItem.media_type ?? '') + setEditYear(selectedItem.year == null ? '' : String(selectedItem.year)) + setEditExternalRef(selectedItem.external_ref ?? '') + setEditStatus(selectedItem.status ?? 'new') + setEditRequestStatus(selectedItem.workflow?.request_status ?? 'pending') + setEditMediaStatus(selectedItem.workflow?.media_status ?? 'pending') + setEditPriority(selectedItem.priority ?? 'normal') + setEditAssignee(selectedItem.assignee_username ?? '') + }, [selectedItem]) + + const createItem = async (event: React.FormEvent) => { + event.preventDefault() + setCreating(true) + setError(null) + setStatus(null) + try { + const payload: Record = { + kind: workspace, + title: createTitle, + description: createDescription, + media_type: workspace === 'request' ? createMediaType || null : null, + year: workspace === 'request' && createYear.trim() ? toPositiveInt(createYear) : null, + external_ref: createExternalRef || null, + priority: createPriority, + } + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/portal/items`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + const text = await response.text() + throw new Error(text || 'Could not create portal item.') + } + const data = await response.json() + const item = data?.item as PortalItem | undefined + setStatus(workspace === 'request' ? 'Request item created.' : 'Issue item created.') + setCreateTitle('') + setCreateDescription('') + setCreateMediaType('') + setCreateYear('') + setCreateExternalRef('') + setCreatePriority('normal') + await Promise.all([ + loadItems({ preferItemId: item?.id ?? null }), + loadOverview(), + ]) + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not create portal item.') + } finally { + setCreating(false) + } + } + + const saveItem = async (event: React.FormEvent) => { + event.preventDefault() + if (!selectedItem) return + setSaving(true) + setError(null) + setStatus(null) + try { + const payload: Record = { + title: editTitle, + description: editDescription, + media_type: editMediaType || null, + year: editYear.trim() ? toPositiveInt(editYear) : null, + external_ref: editExternalRef || null, + } + if (selectedItem.permissions?.can_moderate) { + if (selectedItem.kind === 'request') { + payload.request_status = editRequestStatus + payload.media_status = editMediaStatus + } else { + payload.status = editStatus + } + payload.priority = editPriority + payload.assignee_username = editAssignee || null + } + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/portal/items/${selectedItem.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + const text = await response.text() + throw new Error(text || 'Could not update portal item.') + } + const data = await response.json() + setSelectedItem((data?.item ?? null) as PortalItem | null) + setComments(Array.isArray(data?.comments) ? data.comments : []) + setStatus('Portal item updated.') + await Promise.all([ + loadItems({ preferItemId: selectedItem.id }), + loadOverview(), + ]) + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not update portal item.') + } finally { + setSaving(false) + } + } + + const postComment = async (event: React.FormEvent) => { + event.preventDefault() + if (!selectedItem) return + if (!commentText.trim()) { + setError('Comment message is required.') + return + } + setCommenting(true) + setError(null) + setStatus(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/portal/items/${selectedItem.id}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: commentText, + is_internal: commentInternal, + }), + }) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + const text = await response.text() + throw new Error(text || 'Could not add comment.') + } + setCommentText('') + setCommentInternal(false) + setStatus('Comment added.') + await Promise.all([ + loadItem(selectedItem.id), + loadItems({ preferItemId: selectedItem.id }), + loadOverview(), + ]) + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not add comment.') + } finally { + setCommenting(false) + } + } + + if (loadingItems && !items.length) { + return
Loading request portal...
+ } + + return ( +
+
+
+

{workspace === 'request' ? 'Request portal' : 'Issue portal'}

+

+ {workspace === 'request' + ? 'Search and track content requests through the delivery pipeline.' + : 'Raise operational issues and manage resolution updates.'} +

+
+
+ +
+ + +
+ + {error &&
{error}
} + {status &&
{status}
} + + {workspace === 'request' ? ( +
+
+
+

Search and request content

+

+ Search Seerr content directly, then submit a request in one click. +

+
+
+
+ setDiscoverQuery(event.target.value)} + placeholder="Search movies or TV shows" + /> + +
+ {discoverError &&
{discoverError}
} +
+ {discoverLoading ? ( +
Searching Seerr…
+ ) : discoverResults.length === 0 ? ( +
No discovery results yet.
+ ) : ( + discoverResults.map((item, index) => { + const key = `${item.type ?? 'unknown'}:${item.tmdbId ?? index}` + const requesting = Boolean(requestingTmdbIds[key]) + const poster = resolveTmdbArtworkUrl(item.posterPath, 'w185') + const hasRequest = typeof item.requestId === 'number' && item.requestId > 0 + return ( +
+
+ {poster ? :
No artwork
} +
+
+
+ {item.title || 'Untitled'} + {item.type ?? 'unknown'} + {item.year ? {item.year} : null} +
+

+ {hasRequest ? ( + <> + Already requested + {item.statusLabel ? ` · ${item.statusLabel}` : ''} + {item.requestId ? ` · #${item.requestId}` : ''} + + ) : ( + 'Not requested yet' + )} +

+
+
+ {hasRequest ? ( + + ) : ( + + )} +
+
+ ) + }) + )} +
+
+ ) : ( +
+
+ Issue workspace is for reporting problems and tracking resolution separately from content requests. +
+
+ )} + +
+
+ Total {workspace === 'request' ? 'requests' : 'issues'} + {visibleKindCount} +
+
+ Total comments + {Number(overview?.overview?.total_comments ?? 0)} +
+
+ My items + {Number(overview?.my_items ?? 0)} +
+
+ Visible + {items.length} +
+
+ +
+

{workspace === 'request' ? 'Create request item' : 'Create issue item'}

+

+ {workspace === 'request' + ? 'Create and track request-related notes in a dedicated request workflow.' + : 'Create and track operational issues in a dedicated issue workflow.'} +

+
+ + +