Release Memby 0.2.64
This commit is contained in:
@@ -33,6 +33,7 @@ import { JourneyViewerPage } from './pages/JourneyViewer';
|
||||
import { EngagementPage } from './pages/Engagement';
|
||||
import { SearchesPage } from './pages/Searches';
|
||||
import { ViewsPage } from './pages/Views';
|
||||
import { MediaReportsPage } from './pages/MediaReports';
|
||||
|
||||
/* The console's routing table.
|
||||
*
|
||||
@@ -91,6 +92,7 @@ export function App() {
|
||||
<Route path="views" element={<ViewsPage />} />
|
||||
<Route path="engagement" element={<EngagementPage />} />
|
||||
<Route path="searches" element={<SearchesPage />} />
|
||||
<Route path="media-reports" element={<MediaReportsPage />} />
|
||||
|
||||
{/* The old console redirected /admin/ to /admin/overview. Anything that
|
||||
still links there lands on the overview rather than on a 404. */}
|
||||
|
||||
@@ -97,10 +97,36 @@ export interface HeroItem {
|
||||
|
||||
export interface HeroPolicy {
|
||||
pinnedItems: HeroItem[] | null;
|
||||
items?: HeroItem[] | null;
|
||||
primeSubtitle: string;
|
||||
placements?: Record<HeroPlacement, HeroPlacementPolicy>;
|
||||
schedules?: HeroSchedule[] | null;
|
||||
}
|
||||
|
||||
export type HeroPlacement = 'home' | 'movies' | 'tv_shows';
|
||||
|
||||
export interface HeroPlacementPolicy {
|
||||
pinnedItems: HeroItem[] | null;
|
||||
primeSubtitle: string;
|
||||
}
|
||||
|
||||
export interface MediaReport {
|
||||
id: number;
|
||||
mediaType: 'movie' | 'episode';
|
||||
title: string;
|
||||
seriesTitle?: string;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
reason: string;
|
||||
comment?: string;
|
||||
reportedByUsername: string;
|
||||
reportedByDevice?: string;
|
||||
replacementRequested: boolean;
|
||||
replacementStatus?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RequestUsage {
|
||||
userId: string;
|
||||
requests: number;
|
||||
@@ -116,6 +142,7 @@ export interface HeroSchedule {
|
||||
priority: number;
|
||||
userId?: string;
|
||||
enabled: boolean;
|
||||
placements?: HeroPlacement[];
|
||||
}
|
||||
|
||||
export interface MDBListSettings {
|
||||
|
||||
@@ -256,6 +256,14 @@ export const nav: NavGroup[] = [
|
||||
id: 'reporting',
|
||||
label: 'Reporting',
|
||||
items: [
|
||||
{
|
||||
id: 'media-reports',
|
||||
path: '/admin/media-reports',
|
||||
label: 'Media reports',
|
||||
title: 'Media reports',
|
||||
intro: 'Problems viewers reported with a film or episode.',
|
||||
icon: 'inbox',
|
||||
},
|
||||
{
|
||||
id: 'views',
|
||||
path: '/admin/views',
|
||||
|
||||
+62
-25
@@ -4,16 +4,24 @@ import { useAction } from '../lib/hooks';
|
||||
import { useGateway } from '../lib/gateway';
|
||||
import { useToast } from '../lib/toast';
|
||||
import { Banner, Button, Card, Empty, Field, Grid, Loading, PageHead } from '../components/ui';
|
||||
import type { HeroItem, HeroSchedule } from '../api/types';
|
||||
import type { HeroItem, HeroPlacement, HeroPlacementPolicy, HeroSchedule } from '../api/types';
|
||||
|
||||
const MAX_PINS = 4;
|
||||
const PLACEMENTS: Array<{ id: HeroPlacement; label: string; type: string }> = [
|
||||
{ id: 'home', label: 'Home', type: 'films and television shows' },
|
||||
{ id: 'movies', label: 'Movies', type: 'films' },
|
||||
{ id: 'tv_shows', label: 'TV Shows', type: 'television shows' },
|
||||
];
|
||||
const emptyPlacement = (): HeroPlacementPolicy => ({ pinnedItems: [], primeSubtitle: '' });
|
||||
|
||||
export function HeroPage() {
|
||||
const { status, error, loading, reload } = useGateway();
|
||||
const { wrap, show } = useToast();
|
||||
const { busy, run } = useAction();
|
||||
const [pins, setPins] = useState<HeroItem[]>([]);
|
||||
const [subtitle, setSubtitle] = useState('');
|
||||
const [placement, setPlacement] = useState<HeroPlacement>('home');
|
||||
const [placements, setPlacements] = useState<Record<HeroPlacement, HeroPlacementPolicy>>({
|
||||
home: emptyPlacement(), movies: emptyPlacement(), tv_shows: emptyPlacement(),
|
||||
});
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [queryText, setQueryText] = useState('');
|
||||
const [results, setResults] = useState<HeroItem[] | null>(null);
|
||||
@@ -24,8 +32,11 @@ export function HeroPage() {
|
||||
useEffect(() => {
|
||||
// The poll must not take an unsaved arrangement away, which is what `dirty` guards.
|
||||
if (dirty || !policy) return;
|
||||
setPins((policy.pinnedItems ?? []).slice(0, MAX_PINS));
|
||||
setSubtitle(policy.primeSubtitle ?? '');
|
||||
setPlacements({
|
||||
home: policy.placements?.home ?? { pinnedItems: policy.pinnedItems ?? [], primeSubtitle: policy.primeSubtitle ?? '' },
|
||||
movies: policy.placements?.movies ?? emptyPlacement(),
|
||||
tv_shows: policy.placements?.tv_shows ?? emptyPlacement(),
|
||||
});
|
||||
setSchedules(policy.schedules ?? []);
|
||||
}, [policy, dirty]);
|
||||
|
||||
@@ -39,14 +50,20 @@ export function HeroPage() {
|
||||
if (payload) setResults(payload.items ?? []);
|
||||
});
|
||||
|
||||
const current = placements[placement];
|
||||
const pins = current.pinnedItems ?? [];
|
||||
const setCurrent = (next: Partial<HeroPlacementPolicy>) => {
|
||||
setPlacements((value) => ({ ...value, [placement]: { ...value[placement], ...next } }));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const add = (item: HeroItem) => {
|
||||
if (pins.some((pin) => pin.id === item.id)) return;
|
||||
if (pins.length >= MAX_PINS) {
|
||||
show('Remove a pinned title before adding another.', 'bad');
|
||||
return;
|
||||
}
|
||||
setPins((current) => [...current, item]);
|
||||
setDirty(true);
|
||||
setCurrent({ pinnedItems: [...pins, item] });
|
||||
};
|
||||
|
||||
const save = () =>
|
||||
@@ -54,8 +71,10 @@ export function HeroPage() {
|
||||
await wrap(
|
||||
() =>
|
||||
api.post('/admin/api/hero-policy', {
|
||||
pinnedItemIds: pins.map((item) => item.id),
|
||||
primeSubtitle: subtitle.trim(),
|
||||
placements: Object.fromEntries(Object.entries(placements).map(([name, value]) => [name, {
|
||||
pinnedItemIds: (value.pinnedItems ?? []).map((item) => item.id),
|
||||
primeSubtitle: value.primeSubtitle.trim(),
|
||||
}])),
|
||||
schedules,
|
||||
}),
|
||||
'Hero saved.',
|
||||
@@ -67,8 +86,8 @@ export function HeroPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title="Home hero"
|
||||
intro="Choose films or television shows for the launcher spotlight while recent releases fill the remaining places."
|
||||
title="Featured content"
|
||||
intro="Manage an independent, backend-resolved hero for Home, Movies and TV Shows."
|
||||
/>
|
||||
<Banner message={error} />
|
||||
|
||||
@@ -76,9 +95,30 @@ export function HeroPage() {
|
||||
<Loading rows={2} />
|
||||
) : (
|
||||
<>
|
||||
<Grid>
|
||||
{PLACEMENTS.map((option) => {
|
||||
const configured = placements[option.id];
|
||||
const activeSchedule = schedules
|
||||
.filter((entry) => entry.enabled && (entry.placements ?? ['home']).includes(option.id) && new Date(entry.startAt) <= new Date() && new Date(entry.endAt) > new Date())
|
||||
.sort((left, right) => right.priority - left.priority)[0];
|
||||
const source = (configured.pinnedItems ?? []).length ? 'Manual' : activeSchedule ? 'Scheduled' : 'Automatic';
|
||||
const scheduledItem = policy?.items?.find((item) => item.id === activeSchedule?.itemId);
|
||||
const preview = (configured.pinnedItems ?? []).map((item) => item.name).join(', ') || scheduledItem?.name || activeSchedule?.itemId || 'Resolved for each viewer';
|
||||
return <Card key={option.id} title={option.label} intro={`${source} · ${preview}`} tone={option.id === placement ? 'info' : undefined}>
|
||||
<Button size="sm" variant="quiet" onClick={() => setPlacement(option.id)}>Manage {option.label}</Button>
|
||||
</Card>;
|
||||
})}
|
||||
</Grid>
|
||||
<div className="tabs" role="tablist" aria-label="Hero placement">
|
||||
{PLACEMENTS.map((option) => (
|
||||
<Button key={option.id} variant={placement === option.id ? 'primary' : 'quiet'} onClick={() => setPlacement(option.id)}>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Card
|
||||
title="Pinned titles"
|
||||
intro="Pinned films and series lead the four-card launcher grid in this order. Empty places are filled by Memby's existing mix of recent digital releases, premieres and highly rated library titles. Pinning changes placement only; labels and reasons remain natural."
|
||||
title={`${PLACEMENTS.find((entry) => entry.id === placement)?.label} hero`}
|
||||
intro={`Pinned ${PLACEMENTS.find((entry) => entry.id === placement)?.type} lead this section only. Empty places use this placement’s automatic selection.`}
|
||||
icon="star"
|
||||
tone="note"
|
||||
footer={
|
||||
@@ -88,8 +128,7 @@ export function HeroPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPins([]);
|
||||
setDirty(true);
|
||||
setCurrent({ pinnedItems: [] });
|
||||
}}
|
||||
>
|
||||
Clear pins
|
||||
@@ -109,8 +148,7 @@ export function HeroPage() {
|
||||
size="sm"
|
||||
icon="close"
|
||||
onClick={() => {
|
||||
setPins((current) => current.filter((pin) => pin.id !== item.id));
|
||||
setDirty(true);
|
||||
setCurrent({ pinnedItems: pins.filter((pin) => pin.id !== item.id) });
|
||||
}}
|
||||
>
|
||||
{index + 1}. {item.name}
|
||||
@@ -127,11 +165,10 @@ export function HeroPage() {
|
||||
<input
|
||||
type="text"
|
||||
maxLength={160}
|
||||
value={subtitle}
|
||||
value={current.primeSubtitle}
|
||||
placeholder="Leave blank for the automatic reason"
|
||||
onChange={(event) => {
|
||||
setSubtitle(event.target.value);
|
||||
setDirty(true);
|
||||
setCurrent({ primeSubtitle: event.target.value });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
@@ -140,20 +177,20 @@ export function HeroPage() {
|
||||
<Card title="Scheduled heroes" intro="Schedules are resolved by the gateway: manual pins still win, then the highest-priority eligible schedule, then Memby’s automatic hero." icon="clock" tone="info">
|
||||
{schedules.length === 0 ? <Empty>No scheduled heroes yet.</Empty> : (
|
||||
<div className="stack">{schedules.map((schedule) => {
|
||||
const item = [...pins, ...(results ?? [])].find((candidate) => candidate.id === schedule.itemId);
|
||||
return <div className="row" key={schedule.id}><b>{item?.name ?? schedule.itemId}</b><span className="muted">{new Date(schedule.startAt).toLocaleString()} → {new Date(schedule.endAt).toLocaleString()} · priority {schedule.priority}</span><Button size="sm" variant="quiet" onClick={() => { setSchedules((current) => current.filter((entry) => entry.id !== schedule.id)); setDirty(true); }}>Remove</Button></div>;
|
||||
const item = [...(policy?.items ?? []), ...pins, ...(results ?? [])].find((candidate) => candidate.id === schedule.itemId);
|
||||
return <div className="row" key={schedule.id}><b>{item?.name ?? schedule.itemId}</b><span className="muted">{new Date(schedule.startAt).toLocaleString()} → {new Date(schedule.endAt).toLocaleString()} · priority {schedule.priority} · {(schedule.placements ?? ['home']).map((value) => PLACEMENTS.find((entry) => entry.id === value)?.label).join(', ')}</span><div className="chips">{PLACEMENTS.map((option) => <Button key={option.id} size="sm" variant={(schedule.placements ?? ['home']).includes(option.id) ? 'primary' : 'quiet'} onClick={() => { setSchedules((all) => all.map((entry) => { if (entry.id !== schedule.id) return entry; const selected = entry.placements ?? ['home']; const next = selected.includes(option.id) ? selected.filter((value) => value !== option.id) : [...selected, option.id]; return { ...entry, placements: next.length ? next : [option.id] }; })); setDirty(true); }}>{option.label}</Button>)}</div><Button size="sm" variant="quiet" onClick={() => { setSchedules((current) => current.filter((entry) => entry.id !== schedule.id)); setDirty(true); }}>Remove</Button></div>;
|
||||
})}</div>
|
||||
)}
|
||||
{pins.length > 0 ? <Button size="sm" icon="plus" onClick={() => {
|
||||
const first = pins[0]; if (!first) return;
|
||||
const start = new Date(); const end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
|
||||
setSchedules((current) => [...current, { id: crypto.randomUUID(), itemId: first.id, startAt: start.toISOString(), endAt: end.toISOString(), priority: 0, enabled: true }]); setDirty(true);
|
||||
setSchedules((current) => [...current, { id: crypto.randomUUID(), itemId: first.id, startAt: start.toISOString(), endAt: end.toISOString(), priority: 0, enabled: true, placements: [placement] }]); setDirty(true);
|
||||
}}>Schedule first pinned title for two hours</Button> : <p className="hint">Pin or search for a title first, then add it to a schedule.</p>}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Find a title"
|
||||
intro="Search the imported Emby catalogue. Up to four films or series can be pinned."
|
||||
intro={`Search the imported Emby catalogue. Add a result to the selected ${PLACEMENTS.find((entry) => entry.id === placement)?.label} placement; switch tabs to show it in more than one section.`}
|
||||
icon="search"
|
||||
tone="info"
|
||||
>
|
||||
@@ -183,7 +220,7 @@ export function HeroPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
icon="plus"
|
||||
disabled={pins.some((pin) => pin.id === item.id)}
|
||||
disabled={pins.some((pin) => pin.id === item.id) || (placement === 'movies' && item.type !== 'Movie') || (placement === 'tv_shows' && item.type !== 'Series')}
|
||||
onClick={() => add(item)}
|
||||
>
|
||||
{pins.some((pin) => pin.id === item.id) ? 'Pinned' : 'Add to hero'}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { api } from '../api/client';
|
||||
import type { MediaReport } from '../api/types';
|
||||
import { useAction, useQuery } from '../lib/hooks';
|
||||
import { when } from '../lib/format';
|
||||
import { Button, Card, Empty, Loading, PageHead, TableWrap, Tag } from '../components/ui';
|
||||
|
||||
const label = (report: MediaReport) => report.mediaType === 'episode'
|
||||
? `${report.seriesTitle} S${String(report.seasonNumber).padStart(2, '0')}E${String(report.episodeNumber).padStart(2, '0')}`
|
||||
: report.title;
|
||||
|
||||
export function MediaReportsPage() {
|
||||
const query = useQuery<{ reports: MediaReport[] }>('/admin/api/media-reports', { pollMs: 15_000 });
|
||||
const { busy, run } = useAction();
|
||||
const setStatus = (report: MediaReport, status: string) => run(`${report.id}-${status}`, async () => {
|
||||
await api.post(`/admin/api/media-reports/${report.id}/status`, { status });
|
||||
await query.reload();
|
||||
});
|
||||
return <><PageHead title="Media reports" intro="Viewer-reported problems and the individual replacement searches they asked Memby to start." />
|
||||
{query.loading ? <Loading rows={4} /> : <Card title="Open and recent reports" intro="A replacement always targets one film or one episode. Existing files stay in place while Radarr or Sonarr applies its normal import policy." icon="inbox">
|
||||
{!query.data?.reports.length ? <Empty>No media problems have been reported.</Empty> : <TableWrap><table><thead><tr><th>Media</th><th>Report</th><th>Viewer</th><th>Replacement</th><th>Status</th><th>Reported</th><th>Actions</th></tr></thead><tbody>{query.data.reports.map((report) => <tr key={report.id}><td><b>{label(report)}</b><br /><span className="muted">{report.title}</span></td><td>{report.reason.replaceAll('_', ' ')}{report.comment ? <><br /><span className="muted">{report.comment}</span></> : null}</td><td>{report.reportedByUsername}<br /><span className="muted">{report.reportedByDevice || 'Unknown device'}</span></td><td><Tag tone={report.replacementRequested ? 'note' : undefined}>{report.replacementRequested ? report.replacementStatus || 'Requested' : 'Not requested'}</Tag></td><td><Tag tone={report.status === 'resolved' ? 'ok' : report.status === 'dismissed' ? undefined : 'warn'}>{report.status}</Tag></td><td className="nowrap muted">{when(report.createdAt)}</td><td><Button size="sm" variant="quiet" busy={busy === `${report.id}-acknowledged`} onClick={() => void setStatus(report, 'acknowledged')}>Acknowledge</Button>{' '}<Button size="sm" variant="quiet" busy={busy === `${report.id}-resolved`} onClick={() => void setStatus(report, 'resolved')}>Resolve</Button>{' '}<Button size="sm" variant="quiet" busy={busy === `${report.id}-dismissed`} onClick={() => void setStatus(report, 'dismissed')}>Dismiss</Button></td></tr>)}</tbody></table></TableWrap>}
|
||||
</Card>}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user