'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 } const KIND_OPTIONS = [ { value: 'request', label: 'Request' }, { value: 'issue', label: 'Issue' }, { value: 'feature', label: 'Feature' }, ] as const 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 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 } export default function PortalPage() { 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, setFilterKind] = useState('') const [filterStatus, setFilterStatus] = useState('') const [filterMine, setFilterMine] = useState(false) const [filterSearch, setFilterSearch] = useState('') const [createKind, setCreateKind] = useState<'request' | 'issue' | 'feature'>('request') 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 isAdmin = me?.role === 'admin' 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', }) if (filterKind) 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) } } 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 }, [filterKind, filterStatus, filterMine, filterSearch]) 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: createKind, title: createTitle, description: createDescription, media_type: createMediaType || null, year: 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('Portal 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 (

Request portal

Raise requests, issues, and feature ideas. Track progress and keep discussion in one place.

{error &&
{error}
} {status &&
{status}
}
Total items {Number(overview?.overview?.total_items ?? totalItems ?? 0)}
Total comments {Number(overview?.overview?.total_comments ?? 0)}
My items {Number(overview?.my_items ?? 0)}
Visible {items.length}

Create item

Use Request for new content, Issue for broken behavior, and Feature for improvements.