1142 lines
40 KiB
TypeScript
1142 lines
40 KiB
TypeScript
'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<string, number>
|
|
by_status?: Record<string, number>
|
|
}
|
|
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 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<UserProfile | null>(null)
|
|
const [overview, setOverview] = useState<PortalOverview | null>(null)
|
|
const [items, setItems] = useState<PortalItem[]>([])
|
|
const [selectedItemId, setSelectedItemId] = useState<number | null>(null)
|
|
const [selectedItem, setSelectedItem] = useState<PortalItem | null>(null)
|
|
const [comments, setComments] = useState<PortalComment[]>([])
|
|
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<string | null>(null)
|
|
const [status, setStatus] = useState<string | null>(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<number | null>(null)
|
|
const [discoverQuery, setDiscoverQuery] = useState('')
|
|
const [discoverLoading, setDiscoverLoading] = useState(false)
|
|
const [discoverResults, setDiscoverResults] = useState<DiscoveryResult[]>([])
|
|
const [discoverError, setDiscoverError] = useState<string | null>(null)
|
|
const [requestingTmdbIds, setRequestingTmdbIds] = useState<Record<string, boolean>>({})
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}, [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<string, any> = {
|
|
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<string, any> = {
|
|
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 <main className="card">Loading request portal...</main>
|
|
}
|
|
|
|
return (
|
|
<main className="card portal-page">
|
|
<div className="user-directory-panel-header">
|
|
<div>
|
|
<h1>Request portal</h1>
|
|
<p className="lede">
|
|
Raise requests, issues, and feature ideas. Track progress and keep discussion in one place.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <div className="error-banner">{error}</div>}
|
|
{status && <div className="status-banner">{status}</div>}
|
|
|
|
<section className="admin-panel portal-discovery-panel">
|
|
<div className="user-directory-panel-header">
|
|
<div>
|
|
<h2>Search and request content</h2>
|
|
<p className="lede">
|
|
Search Seerr content directly, then submit a request in one click.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<form className="portal-discovery-form" onSubmit={runDiscoverySearch}>
|
|
<input
|
|
value={discoverQuery}
|
|
onChange={(event) => setDiscoverQuery(event.target.value)}
|
|
placeholder="Search movies or TV shows"
|
|
/>
|
|
<button type="submit" disabled={discoverLoading}>
|
|
{discoverLoading ? 'Searching…' : 'Search'}
|
|
</button>
|
|
</form>
|
|
{discoverError && <div className="error-banner">{discoverError}</div>}
|
|
<div className="portal-discovery-results">
|
|
{discoverLoading ? (
|
|
<div className="status-banner">Searching Seerr…</div>
|
|
) : discoverResults.length === 0 ? (
|
|
<div className="status-banner">No discovery results yet.</div>
|
|
) : (
|
|
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 (
|
|
<div key={key} className="portal-discovery-item">
|
|
<div className="portal-discovery-media">
|
|
{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>
|
|
<p>
|
|
{hasRequest ? (
|
|
<>
|
|
Already requested
|
|
{item.statusLabel ? ` · ${item.statusLabel}` : ''}
|
|
{item.requestId ? ` · #${item.requestId}` : ''}
|
|
</>
|
|
) : (
|
|
'Not requested yet'
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="portal-discovery-actions">
|
|
{hasRequest ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push(`/requests/${item.requestId}`)}
|
|
>
|
|
Open request
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => void requestDiscoveryItem(item)}
|
|
disabled={requesting || !item.tmdbId || !item.type}
|
|
>
|
|
{requesting ? 'Requesting…' : 'Request'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="portal-overview-grid">
|
|
<div className="portal-overview-card">
|
|
<span>Total items</span>
|
|
<strong>{Number(overview?.overview?.total_items ?? totalItems ?? 0)}</strong>
|
|
</div>
|
|
<div className="portal-overview-card">
|
|
<span>Total comments</span>
|
|
<strong>{Number(overview?.overview?.total_comments ?? 0)}</strong>
|
|
</div>
|
|
<div className="portal-overview-card">
|
|
<span>My items</span>
|
|
<strong>{Number(overview?.my_items ?? 0)}</strong>
|
|
</div>
|
|
<div className="portal-overview-card">
|
|
<span>Visible</span>
|
|
<strong>{items.length}</strong>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="admin-panel portal-create-panel">
|
|
<h2>Create item</h2>
|
|
<p className="lede">
|
|
Use <strong>Request</strong> for new content, <strong>Issue</strong> for broken behavior, and <strong>Feature</strong> for improvements.
|
|
</p>
|
|
<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>
|
|
<span>Priority</span>
|
|
<select
|
|
value={createPriority}
|
|
onChange={(event) =>
|
|
setCreatePriority(event.target.value as 'low' | 'normal' | 'high' | 'urgent')
|
|
}
|
|
>
|
|
{PRIORITY_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="portal-field-span-2">
|
|
<span>Title</span>
|
|
<input
|
|
required
|
|
value={createTitle}
|
|
onChange={(event) => setCreateTitle(event.target.value)}
|
|
placeholder="Short summary of the request or issue"
|
|
/>
|
|
</label>
|
|
<label className="portal-field-span-2">
|
|
<span>Description</span>
|
|
<textarea
|
|
required
|
|
rows={4}
|
|
value={createDescription}
|
|
onChange={(event) => setCreateDescription(event.target.value)}
|
|
placeholder="Add details, expected behavior, and any context."
|
|
/>
|
|
</label>
|
|
<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">
|
|
<span>External reference</span>
|
|
<input
|
|
value={createExternalRef}
|
|
onChange={(event) => setCreateExternalRef(event.target.value)}
|
|
placeholder="Optional: URL, ticket number, or request id"
|
|
/>
|
|
</label>
|
|
<div className="admin-inline-actions portal-field-span-2">
|
|
<button type="submit" disabled={creating}>
|
|
{creating ? 'Creating…' : 'Create portal item'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<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>
|
|
<span>Status</span>
|
|
<select value={filterStatus} onChange={(event) => setFilterStatus(event.target.value)}>
|
|
<option value="">All</option>
|
|
{STATUS_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="portal-search-filter">
|
|
<span>Search</span>
|
|
<input
|
|
value={filterSearch}
|
|
onChange={(event) => setFilterSearch(event.target.value)}
|
|
placeholder="Title, description, or item id"
|
|
/>
|
|
</label>
|
|
<label className="inline-checkbox portal-mine-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={filterMine}
|
|
onChange={(event) => setFilterMine(event.target.checked)}
|
|
/>
|
|
My items only
|
|
</label>
|
|
</section>
|
|
|
|
<div className="portal-workspace">
|
|
<section className="admin-panel portal-list-panel">
|
|
<div className="user-directory-panel-header">
|
|
<div>
|
|
<h2>Items</h2>
|
|
<p className="lede">
|
|
{totalItems} total
|
|
{hasMore ? ' (showing first 60)' : ''}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{items.length === 0 ? (
|
|
<div className="status-banner">No portal items match this filter.</div>
|
|
) : (
|
|
<div className="portal-item-list">
|
|
{items.map((item) => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
className={`portal-item-row ${selectedItemId === item.id ? 'is-active' : ''}`}
|
|
onClick={() => setSelectedItemId(item.id)}
|
|
>
|
|
<div className="portal-item-row-main">
|
|
<div className="portal-item-row-title">
|
|
<strong>{item.title}</strong>
|
|
<span className="small-pill">{item.kind}</span>
|
|
<span className="small-pill is-muted">{item.priority}</span>
|
|
</div>
|
|
<p>{item.description}</p>
|
|
<div className="portal-item-row-meta">
|
|
<span>#{item.id}</span>
|
|
<span>
|
|
Status:{' '}
|
|
{item.kind === 'request'
|
|
? item.workflow?.stage_label ?? item.status
|
|
: item.status}
|
|
</span>
|
|
<span>By: {item.created_by_username}</span>
|
|
<span>Updated: {formatDate(item.last_activity_at)}</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="admin-panel portal-detail-panel">
|
|
{!selectedItemId ? (
|
|
<div className="status-banner">Select an item to view details.</div>
|
|
) : loadingItem ? (
|
|
<div className="status-banner">Loading details…</div>
|
|
) : !selectedItem ? (
|
|
<div className="status-banner">Item not found.</div>
|
|
) : (
|
|
<>
|
|
<div className="user-directory-panel-header">
|
|
<div>
|
|
<h2>Item #{selectedItem.id}</h2>
|
|
<p className="lede">
|
|
Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)}
|
|
</p>
|
|
{selectedItem.kind === 'request' && (
|
|
<p className="lede">
|
|
Pipeline:{' '}
|
|
<strong>
|
|
{selectedItem.workflow?.request_status ?? 'pending'} /{' '}
|
|
{selectedItem.workflow?.media_status ?? 'pending'}
|
|
</strong>{' '}
|
|
({selectedItem.workflow?.stage_label ?? 'Pending'})
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
|
|
<label className="portal-field-span-2">
|
|
<span>Title</span>
|
|
<input
|
|
value={editTitle}
|
|
onChange={(event) => setEditTitle(event.target.value)}
|
|
disabled={!selectedItem.permissions?.can_edit}
|
|
/>
|
|
</label>
|
|
<label className="portal-field-span-2">
|
|
<span>Description</span>
|
|
<textarea
|
|
rows={4}
|
|
value={editDescription}
|
|
onChange={(event) => setEditDescription(event.target.value)}
|
|
disabled={!selectedItem.permissions?.can_edit}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>Media type</span>
|
|
<select
|
|
value={editMediaType}
|
|
onChange={(event) => setEditMediaType(event.target.value)}
|
|
disabled={!selectedItem.permissions?.can_edit}
|
|
>
|
|
{MEDIA_TYPE_OPTIONS.map((option) => (
|
|
<option key={option.value || 'none'} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>Year</span>
|
|
<input
|
|
value={editYear}
|
|
onChange={(event) => setEditYear(event.target.value)}
|
|
inputMode="numeric"
|
|
disabled={!selectedItem.permissions?.can_edit}
|
|
/>
|
|
</label>
|
|
<label className="portal-field-span-2">
|
|
<span>External reference</span>
|
|
<input
|
|
value={editExternalRef}
|
|
onChange={(event) => setEditExternalRef(event.target.value)}
|
|
disabled={!selectedItem.permissions?.can_edit}
|
|
/>
|
|
</label>
|
|
{selectedItem.permissions?.can_moderate && (
|
|
<>
|
|
{selectedItem.kind === 'request' ? (
|
|
<>
|
|
<label>
|
|
<span>Request status</span>
|
|
<select
|
|
value={editRequestStatus}
|
|
onChange={(event) => setEditRequestStatus(event.target.value)}
|
|
>
|
|
{REQUEST_STATUS_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>Media status</span>
|
|
<select
|
|
value={editMediaStatus}
|
|
onChange={(event) => setEditMediaStatus(event.target.value)}
|
|
>
|
|
{MEDIA_STATUS_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</>
|
|
) : (
|
|
<label>
|
|
<span>Status</span>
|
|
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value)}>
|
|
{STATUS_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
<label>
|
|
<span>Priority</span>
|
|
<select
|
|
value={editPriority}
|
|
onChange={(event) => setEditPriority(event.target.value)}
|
|
>
|
|
{PRIORITY_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="portal-field-span-2">
|
|
<span>Assignee username</span>
|
|
<input
|
|
value={editAssignee}
|
|
onChange={(event) => setEditAssignee(event.target.value)}
|
|
placeholder="Optional assignee"
|
|
/>
|
|
</label>
|
|
</>
|
|
)}
|
|
<div className="admin-inline-actions portal-field-span-2">
|
|
<button
|
|
type="submit"
|
|
disabled={saving || !selectedItem.permissions?.can_edit}
|
|
>
|
|
{saving ? 'Saving…' : 'Save changes'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<div className="portal-comments-block">
|
|
<h3>Comments</h3>
|
|
{comments.length === 0 ? (
|
|
<div className="status-banner">No comments yet.</div>
|
|
) : (
|
|
<div className="portal-comment-list">
|
|
{comments.map((comment) => (
|
|
<article key={comment.id} className="portal-comment-card">
|
|
<header>
|
|
<strong>{comment.author_username}</strong>
|
|
<span className="small-pill">{comment.author_role}</span>
|
|
{comment.is_internal && <span className="small-pill is-muted">internal</span>}
|
|
<span>{formatDate(comment.created_at)}</span>
|
|
</header>
|
|
<p>{comment.message}</p>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
<form onSubmit={postComment} className="admin-form compact-form portal-comment-form">
|
|
<label>
|
|
<span>Add comment</span>
|
|
<textarea
|
|
rows={3}
|
|
value={commentText}
|
|
onChange={(event) => setCommentText(event.target.value)}
|
|
placeholder="Add an update, troubleshooting note, or next step."
|
|
/>
|
|
</label>
|
|
{isAdmin && (
|
|
<label className="inline-checkbox">
|
|
<input
|
|
type="checkbox"
|
|
checked={commentInternal}
|
|
onChange={(event) => setCommentInternal(event.target.checked)}
|
|
/>
|
|
Internal comment (admin only)
|
|
</label>
|
|
)}
|
|
<div className="admin-inline-actions">
|
|
<button type="submit" disabled={commenting}>
|
|
{commenting ? 'Posting…' : 'Post comment'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|