Build 2602261523: live updates, invite cleanup and nuclear resync
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
import AdminShell from '../ui/AdminShell'
|
||||
@@ -141,6 +141,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
const [artworkSummaryStatus, setArtworkSummaryStatus] = useState<string | null>(null)
|
||||
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
|
||||
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
|
||||
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
|
||||
const requestsSyncRef = useRef<any | null>(null)
|
||||
const artworkPrefetchRef = useRef<any | null>(null)
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
const baseUrl = getApiBase()
|
||||
@@ -338,6 +341,14 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
return false
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
requestsSyncRef.current = requestsSync
|
||||
}, [requestsSync])
|
||||
|
||||
useEffect(() => {
|
||||
artworkPrefetchRef.current = artworkPrefetch
|
||||
}, [artworkPrefetch])
|
||||
|
||||
const settingDescriptions: Record<string, string> = {
|
||||
jellyseerr_base_url:
|
||||
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
|
||||
@@ -576,7 +587,100 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!artworkPrefetch || artworkPrefetch.status !== 'running') {
|
||||
const shouldSubscribe = showRequestsExtras || showArtworkExtras || showLogs
|
||||
if (!shouldSubscribe) {
|
||||
setLiveStreamConnected(false)
|
||||
return
|
||||
}
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
setLiveStreamConnected(false)
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = getApiBase()
|
||||
const params = new URLSearchParams()
|
||||
params.set('access_token', token)
|
||||
if (showLogs) {
|
||||
params.set('include_logs', '1')
|
||||
params.set('log_lines', String(logsCount))
|
||||
}
|
||||
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
|
||||
let closed = false
|
||||
const source = new EventSource(streamUrl)
|
||||
|
||||
source.onopen = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
}
|
||||
|
||||
source.onmessage = (event) => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (!payload || payload.type !== 'admin_live_state') {
|
||||
return
|
||||
}
|
||||
|
||||
const rawSync =
|
||||
payload.requestsSync && typeof payload.requestsSync === 'object'
|
||||
? payload.requestsSync
|
||||
: null
|
||||
const nextSync = rawSync?.status === 'idle' ? null : rawSync
|
||||
const prevSync = requestsSyncRef.current
|
||||
requestsSyncRef.current = nextSync
|
||||
setRequestsSync(nextSync)
|
||||
if (prevSync?.status === 'running' && nextSync?.status && nextSync.status !== 'running') {
|
||||
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
|
||||
}
|
||||
|
||||
const rawArtwork =
|
||||
payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
|
||||
? payload.artworkPrefetch
|
||||
: null
|
||||
const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
|
||||
const prevArtwork = artworkPrefetchRef.current
|
||||
artworkPrefetchRef.current = nextArtwork
|
||||
setArtworkPrefetch(nextArtwork)
|
||||
if (
|
||||
prevArtwork?.status === 'running' &&
|
||||
nextArtwork?.status &&
|
||||
nextArtwork.status !== 'running'
|
||||
) {
|
||||
setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
|
||||
if (showArtworkExtras) {
|
||||
void loadArtworkSummary()
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.logs && typeof payload.logs === 'object') {
|
||||
if (Array.isArray(payload.logs.lines)) {
|
||||
setLogsLines(payload.logs.lines)
|
||||
setLogsStatus(null)
|
||||
} else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
|
||||
setLogsStatus(payload.logs.error)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
closed = true
|
||||
setLiveStreamConnected(false)
|
||||
source.close()
|
||||
}
|
||||
}, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
|
||||
|
||||
useEffect(() => {
|
||||
if (liveStreamConnected || !artworkPrefetch || artworkPrefetch.status !== 'running') {
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
@@ -602,7 +706,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
active = false
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [artworkPrefetch, loadArtworkSummary])
|
||||
}, [artworkPrefetch, liveStreamConnected, loadArtworkSummary])
|
||||
|
||||
useEffect(() => {
|
||||
if (!artworkPrefetch || artworkPrefetch.status === 'running') {
|
||||
@@ -615,7 +719,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
}, [artworkPrefetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestsSync || requestsSync.status !== 'running') {
|
||||
if (liveStreamConnected || !requestsSync || requestsSync.status !== 'running') {
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
@@ -640,7 +744,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
active = false
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [requestsSync])
|
||||
}, [liveStreamConnected, requestsSync])
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestsSync || requestsSync.status === 'running') {
|
||||
@@ -683,12 +787,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
if (!showLogs) {
|
||||
return
|
||||
}
|
||||
if (liveStreamConnected) {
|
||||
return
|
||||
}
|
||||
void loadLogs()
|
||||
const timer = setInterval(() => {
|
||||
void loadLogs()
|
||||
}, 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [loadLogs, showLogs])
|
||||
}, [liveStreamConnected, loadLogs, showLogs])
|
||||
|
||||
const loadCache = async () => {
|
||||
setCacheStatus(null)
|
||||
@@ -763,7 +870,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
setMaintenanceBusy(true)
|
||||
if (typeof window !== 'undefined') {
|
||||
const ok = window.confirm(
|
||||
'This will clear cached requests and history, then re-sync from Jellyseerr. Continue?'
|
||||
'This will perform a nuclear reset: clear cached requests/history, wipe non-admin users, invites, and profiles, then re-sync users and requests from Jellyseerr. Continue?'
|
||||
)
|
||||
if (!ok) {
|
||||
setMaintenanceBusy(false)
|
||||
@@ -772,7 +879,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
}
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
setMaintenanceStatus('Flushing database...')
|
||||
setMaintenanceStatus('Running nuclear flush...')
|
||||
const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, {
|
||||
method: 'POST',
|
||||
})
|
||||
@@ -780,12 +887,25 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
const text = await flushResponse.text()
|
||||
throw new Error(text || 'Flush failed')
|
||||
}
|
||||
setMaintenanceStatus('Database flushed. Starting re-sync...')
|
||||
const flushData = await flushResponse.json()
|
||||
const usersCleared = Number(flushData?.userObjectsCleared?.users ?? 0)
|
||||
setMaintenanceStatus(`Nuclear flush complete. Cleared ${usersCleared} non-admin users. Re-syncing users...`)
|
||||
const usersResyncResponse = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!usersResyncResponse.ok) {
|
||||
const text = await usersResyncResponse.text()
|
||||
throw new Error(text || 'User resync failed')
|
||||
}
|
||||
const usersResyncData = await usersResyncResponse.json()
|
||||
setMaintenanceStatus(
|
||||
`Users re-synced (${usersResyncData?.imported ?? 0} imported). Starting request re-sync...`
|
||||
)
|
||||
await syncRequests()
|
||||
setMaintenanceStatus('Database flushed. Re-sync running now.')
|
||||
setMaintenanceStatus('Nuclear flush complete. User and request re-sync running now.')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setMaintenanceStatus('Flush + resync failed.')
|
||||
setMaintenanceStatus('Nuclear flush + resync failed.')
|
||||
} finally {
|
||||
setMaintenanceBusy(false)
|
||||
}
|
||||
@@ -1452,7 +1572,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
<h2>Maintenance</h2>
|
||||
</div>
|
||||
<div className="status-banner">
|
||||
Emergency tools. Use with care: flush will clear saved requests and history.
|
||||
Emergency tools. Use with care: flush + resync now performs a nuclear wipe of non-admin users, invite links, profiles, cached requests, and history before re-syncing Jellyseerr users/requests.
|
||||
</div>
|
||||
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
|
||||
<div className="maintenance-grid">
|
||||
@@ -1471,7 +1591,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
onClick={runFlushAndResync}
|
||||
disabled={maintenanceBusy}
|
||||
>
|
||||
Flush database + resync
|
||||
Nuclear flush + resync
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -477,26 +477,6 @@ export default function AdminInviteManagementPage() {
|
||||
<button type="button" onClick={loadData} disabled={loading}>
|
||||
{loading ? 'Loading…' : 'Reload'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
resetInviteEditor()
|
||||
setActiveTab('invites')
|
||||
}}
|
||||
>
|
||||
New invite
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
resetProfileEditor()
|
||||
setActiveTab('profiles')
|
||||
}}
|
||||
>
|
||||
New profile
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -504,57 +484,99 @@ export default function AdminInviteManagementPage() {
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
|
||||
<div className="invite-admin-summary-grid">
|
||||
<div className="admin-summary-tile invite-admin-summary-tile">
|
||||
<span className="label">Invites</span>
|
||||
<strong>{invites.length}</strong>
|
||||
<small>{usableInvites} usable • {disabledInvites} disabled</small>
|
||||
<div className="admin-panel invite-admin-summary-panel">
|
||||
<div className="invite-admin-summary-header">
|
||||
<div>
|
||||
<h2>Overview</h2>
|
||||
<p className="lede">
|
||||
Quick counts for invite links, profiles, and managed user defaults.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-summary-tile invite-admin-summary-tile">
|
||||
<span className="label">Profiles</span>
|
||||
<strong>{profiles.length}</strong>
|
||||
<small>{activeProfiles} active profiles</small>
|
||||
</div>
|
||||
<div className="admin-summary-tile invite-admin-summary-tile">
|
||||
<span className="label">Non-admin users</span>
|
||||
<strong>{nonAdminUsers.length}</strong>
|
||||
<small>{profiledUsers} with profile</small>
|
||||
</div>
|
||||
<div className="admin-summary-tile invite-admin-summary-tile">
|
||||
<span className="label">Expiry rules</span>
|
||||
<strong>{expiringUsers}</strong>
|
||||
<small>users with custom expiry</small>
|
||||
<div className="invite-admin-summary-list">
|
||||
<div className="invite-admin-summary-row">
|
||||
<span className="label">Invites</span>
|
||||
<div className="invite-admin-summary-row__value">
|
||||
<strong>{invites.length}</strong>
|
||||
<span>{usableInvites} usable • {disabledInvites} disabled</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="invite-admin-summary-row">
|
||||
<span className="label">Profiles</span>
|
||||
<div className="invite-admin-summary-row__value">
|
||||
<strong>{profiles.length}</strong>
|
||||
<span>{activeProfiles} active profiles</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="invite-admin-summary-row">
|
||||
<span className="label">Non-admin users</span>
|
||||
<div className="invite-admin-summary-row__value">
|
||||
<strong>{nonAdminUsers.length}</strong>
|
||||
<span>{profiledUsers} with profile</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="invite-admin-summary-row">
|
||||
<span className="label">Expiry rules</span>
|
||||
<div className="invite-admin-summary-row__value">
|
||||
<strong>{expiringUsers}</strong>
|
||||
<span>users with custom expiry</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'bulk'}
|
||||
className={activeTab === 'bulk' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('bulk')}
|
||||
>
|
||||
Blanket controls
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'profiles'}
|
||||
className={activeTab === 'profiles' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('profiles')}
|
||||
>
|
||||
Profiles
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'invites'}
|
||||
className={activeTab === 'invites' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('invites')}
|
||||
>
|
||||
Invites
|
||||
</button>
|
||||
<div className="invite-admin-tabbar">
|
||||
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'bulk'}
|
||||
className={activeTab === 'bulk' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('bulk')}
|
||||
>
|
||||
Blanket controls
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'profiles'}
|
||||
className={activeTab === 'profiles' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('profiles')}
|
||||
>
|
||||
Profiles
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'invites'}
|
||||
className={activeTab === 'invites' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('invites')}
|
||||
>
|
||||
Invites
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-inline-actions invite-admin-tab-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
resetInviteEditor()
|
||||
setActiveTab('invites')
|
||||
}}
|
||||
>
|
||||
New invite
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
resetProfileEditor()
|
||||
setActiveTab('profiles')
|
||||
}}
|
||||
>
|
||||
New profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'bulk' && (
|
||||
@@ -815,116 +837,151 @@ export default function AdminInviteManagementPage() {
|
||||
<p className="lede">
|
||||
Link an invite to a profile to apply account defaults at sign-up.
|
||||
</p>
|
||||
<form onSubmit={saveInvite} className="admin-form compact-form">
|
||||
<div className="admin-fields-grid">
|
||||
<label>
|
||||
Code (optional)
|
||||
<input
|
||||
value={inviteForm.code}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, code: e.target.value }))
|
||||
}
|
||||
placeholder="Leave blank to auto-generate"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Label
|
||||
<input
|
||||
value={inviteForm.label}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, label: e.target.value }))
|
||||
}
|
||||
placeholder="Staff invite batch"
|
||||
/>
|
||||
</label>
|
||||
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Identity</span>
|
||||
<small>Code and label used to identify the invite link.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-grid">
|
||||
<label>
|
||||
<span>Code (optional)</span>
|
||||
<input
|
||||
value={inviteForm.code}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, code: e.target.value }))
|
||||
}
|
||||
placeholder="Leave blank to auto-generate"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Label</span>
|
||||
<input
|
||||
value={inviteForm.label}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, label: e.target.value }))
|
||||
}
|
||||
placeholder="Staff invite batch"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
Description
|
||||
<textarea
|
||||
rows={3}
|
||||
value={inviteForm.description}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, description: e.target.value }))
|
||||
}
|
||||
placeholder="Optional note shown on the signup page"
|
||||
/>
|
||||
</label>
|
||||
<div className="admin-fields-grid">
|
||||
<label>
|
||||
Profile
|
||||
<select
|
||||
value={inviteForm.profile_id}
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Description</span>
|
||||
<small>Optional note shown on the signup page.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control">
|
||||
<textarea
|
||||
rows={3}
|
||||
value={inviteForm.description}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, profile_id: e.target.value }))
|
||||
setInviteForm((current) => ({ ...current, description: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{profiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}{profile.is_active === false ? ' (disabled)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Role override
|
||||
<select
|
||||
value={inviteForm.role}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({
|
||||
...current,
|
||||
role: e.target.value as '' | 'user' | 'admin',
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">Use profile/default</option>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="admin-fields-grid">
|
||||
<label>
|
||||
Max uses
|
||||
<input
|
||||
value={inviteForm.max_uses}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, max_uses: e.target.value }))
|
||||
}
|
||||
inputMode="numeric"
|
||||
placeholder="Blank = unlimited"
|
||||
placeholder="Optional note shown on the signup page"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Invite expiry (ISO datetime)
|
||||
<input
|
||||
value={inviteForm.expires_at}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, expires_at: e.target.value }))
|
||||
}
|
||||
placeholder="2026-03-01T12:00:00+00:00"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label className="inline-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.enabled}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, enabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Invite is enabled
|
||||
</label>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="submit" disabled={inviteSaving}>
|
||||
{inviteSaving ? 'Saving…' : inviteEditingId == null ? 'Create invite' : 'Save invite'}
|
||||
</button>
|
||||
{inviteEditingId != null && (
|
||||
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
||||
Cancel edit
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Defaults</span>
|
||||
<small>Choose a profile and optional role override for sign-up.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-grid">
|
||||
<label>
|
||||
<span>Profile</span>
|
||||
<select
|
||||
value={inviteForm.profile_id}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, profile_id: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{profiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}{profile.is_active === false ? ' (disabled)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Role override</span>
|
||||
<select
|
||||
value={inviteForm.role}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({
|
||||
...current,
|
||||
role: e.target.value as '' | 'user' | 'admin',
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">Use profile/default</option>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Limits</span>
|
||||
<small>Usage cap and optional expiry date/time for the invite.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-grid">
|
||||
<label>
|
||||
<span>Max uses</span>
|
||||
<input
|
||||
value={inviteForm.max_uses}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, max_uses: e.target.value }))
|
||||
}
|
||||
inputMode="numeric"
|
||||
placeholder="Blank = unlimited"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Invite expiry (ISO datetime)</span>
|
||||
<input
|
||||
value={inviteForm.expires_at}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, expires_at: e.target.value }))
|
||||
}
|
||||
placeholder="2026-03-01T12:00:00+00:00"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Status</span>
|
||||
<small>Enable or disable the invite before sharing.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||
<label className="inline-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.enabled}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, enabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Invite is enabled
|
||||
</label>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="submit" disabled={inviteSaving}>
|
||||
{inviteSaving ? 'Saving…' : inviteEditingId == null ? 'Create invite' : 'Save invite'}
|
||||
</button>
|
||||
{inviteEditingId != null && (
|
||||
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
||||
Cancel edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1027,6 +1027,30 @@ button span {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Header account menu layering fix */
|
||||
.header {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.signed-in-menu {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.signed-in-dropdown {
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.admin-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -4442,6 +4466,149 @@ button:hover:not(:disabled) {
|
||||
grid-template-columns: minmax(360px, 1.2fr) minmax(300px, 0.8fr);
|
||||
}
|
||||
|
||||
.invite-admin-summary-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.invite-admin-summary-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.invite-admin-summary-header h2 {
|
||||
margin: 0;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.invite-admin-summary-header .lede {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.invite-admin-summary-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.invite-admin-summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 200px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
}
|
||||
|
||||
.invite-admin-summary-row .label {
|
||||
font-size: 0.78rem;
|
||||
color: #9ea7b6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.invite-admin-summary-row__value {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.invite-admin-summary-row__value strong {
|
||||
color: #eef2f7;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.invite-admin-summary-row__value span {
|
||||
color: #b3bcc8;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.invite-admin-tabbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.invite-admin-tabbar .admin-segmented {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.invite-admin-tab-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.invite-form-layout {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.invite-form-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 190px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.invite-form-row-label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.invite-form-row-label > span {
|
||||
color: #e6ebf2;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.invite-form-row-label > small {
|
||||
color: #9ea7b6;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.invite-form-row-control {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.invite-form-row-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.invite-form-row-grid > label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.invite-form-row-grid > label > span {
|
||||
color: #9ea7b6;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.invite-form-row-control textarea,
|
||||
.invite-form-row-control input,
|
||||
.invite-form-row-control select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.invite-form-row-control--stacked {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-panel > h2 + .lede {
|
||||
margin-top: -2px;
|
||||
}
|
||||
@@ -4462,6 +4629,14 @@ button:hover:not(:disabled) {
|
||||
.invite-admin-bulk-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.invite-form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.invite-form-row-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
@@ -4499,4 +4674,21 @@ button:hover:not(:disabled) {
|
||||
.invite-admin-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.invite-admin-summary-row {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.invite-admin-summary-row__value {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.invite-admin-tabbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.invite-admin-tab-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,24 @@ import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth'
|
||||
|
||||
const normalizeRecentResults = (items: any[]) =>
|
||||
items
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => {
|
||||
const id = item.id
|
||||
const rawTitle = item.title
|
||||
const placeholder =
|
||||
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
|
||||
return {
|
||||
id,
|
||||
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
||||
year: item.year,
|
||||
statusLabel: item.statusLabel,
|
||||
artwork: item.artwork,
|
||||
createdAt: item.createdAt ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter()
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -33,6 +51,7 @@ export default function HomePage() {
|
||||
const [servicesError, setServicesError] = useState<string | null>(null)
|
||||
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
|
||||
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
|
||||
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
@@ -137,25 +156,7 @@ export default function HomePage() {
|
||||
}
|
||||
const data = await response.json()
|
||||
if (Array.isArray(data?.results)) {
|
||||
setRecent(
|
||||
data.results
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => {
|
||||
const id = item.id
|
||||
const rawTitle = item.title
|
||||
const placeholder =
|
||||
typeof rawTitle === 'string' &&
|
||||
rawTitle.trim().toLowerCase() === `request ${id}`
|
||||
return {
|
||||
id,
|
||||
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
||||
year: item.year,
|
||||
statusLabel: item.statusLabel,
|
||||
artwork: item.artwork,
|
||||
createdAt: item.createdAt ?? null,
|
||||
}
|
||||
})
|
||||
)
|
||||
setRecent(normalizeRecentResults(data.results))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -196,10 +197,79 @@ export default function HomePage() {
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
void load()
|
||||
if (liveStreamConnected) {
|
||||
return
|
||||
}
|
||||
const timer = setInterval(load, 30000)
|
||||
return () => clearInterval(timer)
|
||||
}, [authReady, router])
|
||||
}, [authReady, liveStreamConnected, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authReady) {
|
||||
setLiveStreamConnected(false)
|
||||
return
|
||||
}
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
setLiveStreamConnected(false)
|
||||
return
|
||||
}
|
||||
const baseUrl = getApiBase()
|
||||
const streamUrl = `${baseUrl}/events/stream?access_token=${encodeURIComponent(token)}&recent_days=${encodeURIComponent(String(recentDays))}`
|
||||
let closed = false
|
||||
const source = new EventSource(streamUrl)
|
||||
|
||||
source.onopen = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
}
|
||||
|
||||
source.onmessage = (event) => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
if (payload.type === 'home_recent') {
|
||||
if (Array.isArray(payload.results)) {
|
||||
setRecent(normalizeRecentResults(payload.results))
|
||||
setRecentError(null)
|
||||
setRecentLoading(false)
|
||||
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
||||
setRecentError('Recent requests are not available right now.')
|
||||
setRecentLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (payload.type === 'home_services') {
|
||||
if (payload.status && typeof payload.status === 'object') {
|
||||
setServicesStatus(payload.status)
|
||||
setServicesError(null)
|
||||
setServicesLoading(false)
|
||||
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
||||
setServicesError('Service status is not available right now.')
|
||||
setServicesLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
closed = true
|
||||
setLiveStreamConnected(false)
|
||||
source.close()
|
||||
}
|
||||
}, [authReady, recentDays])
|
||||
|
||||
const runSearch = async (term: string) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user