0.2.64 update
This commit is contained in:
+343
-35
@@ -3,7 +3,7 @@ import { api } from '../api/client';
|
||||
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 { Banner, Button, Card, Empty, Field, Grid, Loading, PageHead, Tag, Toggle } from '../components/ui';
|
||||
import type { HeroItem, HeroPlacement, HeroPlacementPolicy, HeroSchedule } from '../api/types';
|
||||
|
||||
const MAX_PINS = 4;
|
||||
@@ -13,6 +13,56 @@ const PLACEMENTS: Array<{ id: HeroPlacement; label: string; type: string }> = [
|
||||
{ id: 'tv_shows', label: 'TV Shows', type: 'television shows' },
|
||||
];
|
||||
const emptyPlacement = (): HeroPlacementPolicy => ({ pinnedItems: [], primeSubtitle: '' });
|
||||
const WEEKDAYS = [
|
||||
{ value: 1, short: 'Mon', label: 'Monday' },
|
||||
{ value: 2, short: 'Tue', label: 'Tuesday' },
|
||||
{ value: 3, short: 'Wed', label: 'Wednesday' },
|
||||
{ value: 4, short: 'Thu', label: 'Thursday' },
|
||||
{ value: 5, short: 'Fri', label: 'Friday' },
|
||||
{ value: 6, short: 'Sat', label: 'Saturday' },
|
||||
{ value: 0, short: 'Sun', label: 'Sunday' },
|
||||
];
|
||||
|
||||
type ScheduleFrequency = 'once' | 'daily' | 'weekly';
|
||||
|
||||
function localDateTime(value: Date): string {
|
||||
const offset = value.getTimezoneOffset() * 60_000;
|
||||
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function usableAbsoluteTime(value: string | undefined): boolean {
|
||||
return Boolean(value && Number.isFinite(new Date(value).getTime()) && new Date(value).getFullYear() >= 2000);
|
||||
}
|
||||
|
||||
function scheduleFrequency(schedule: HeroSchedule): ScheduleFrequency {
|
||||
return schedule.frequency ?? 'once';
|
||||
}
|
||||
|
||||
function scheduleSummary(schedule: HeroSchedule): string {
|
||||
if (schedule.frequency === 'daily') {
|
||||
return `Every day · ${schedule.startTime}–${schedule.endTime}`;
|
||||
}
|
||||
if (schedule.frequency === 'weekly') {
|
||||
const selected = WEEKDAYS.filter((day) => (schedule.weekdays ?? []).includes(day.value));
|
||||
const days = selected.length === 7 ? 'Every day' : selected.map((day) => day.short).join(', ');
|
||||
return `${days || 'No days selected'} · ${schedule.startTime}–${schedule.endTime}`;
|
||||
}
|
||||
return `${schedule.startAt ? new Date(schedule.startAt).toLocaleString() : 'Start missing'} → ${schedule.endAt ? new Date(schedule.endAt).toLocaleString() : 'End missing'}`;
|
||||
}
|
||||
|
||||
function newSchedule(item: HeroItem, placement: HeroPlacement): HeroSchedule {
|
||||
const start = new Date();
|
||||
start.setMinutes(Math.ceil(start.getMinutes() / 30) * 30, 0, 0);
|
||||
const end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
|
||||
const validPlacement = placement === 'home' ||
|
||||
(placement === 'movies' && item.type === 'Movie') ||
|
||||
(placement === 'tv_shows' && item.type === 'Series');
|
||||
return {
|
||||
id: crypto.randomUUID(), itemId: item.id,
|
||||
startAt: start.toISOString(), endAt: end.toISOString(),
|
||||
priority: 0, enabled: true, placements: [validPlacement ? placement : 'home'],
|
||||
};
|
||||
}
|
||||
|
||||
export function HeroPage() {
|
||||
const { status, error, loading, reload } = useGateway();
|
||||
@@ -26,6 +76,7 @@ export function HeroPage() {
|
||||
const [queryText, setQueryText] = useState('');
|
||||
const [results, setResults] = useState<HeroItem[] | null>(null);
|
||||
const [schedules, setSchedules] = useState<HeroSchedule[]>([]);
|
||||
const [scheduleDraft, setScheduleDraft] = useState<HeroSchedule | null>(null);
|
||||
|
||||
const policy = status?.heroPolicy;
|
||||
|
||||
@@ -99,9 +150,9 @@ export function HeroPage() {
|
||||
{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())
|
||||
.filter((entry) => entry.enabled && (entry.placements ?? ['home']).includes(option.id))
|
||||
.sort((left, right) => right.priority - left.priority)[0];
|
||||
const source = (configured.pinnedItems ?? []).length ? 'Manual' : activeSchedule ? 'Scheduled' : 'Automatic';
|
||||
const source = (configured.pinnedItems ?? []).length ? 'Manual' : activeSchedule ? 'Schedule ready' : '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}>
|
||||
@@ -140,20 +191,22 @@ export function HeroPage() {
|
||||
{pins.length === 0 ? (
|
||||
<Empty>No titles are pinned. The hero is entirely release-aware and automatic.</Empty>
|
||||
) : (
|
||||
<div className="chips">
|
||||
<div className="hero-pins">
|
||||
{pins.map((item, index) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
icon="close"
|
||||
onClick={() => {
|
||||
setCurrent({ pinnedItems: pins.filter((pin) => pin.id !== item.id) });
|
||||
}}
|
||||
>
|
||||
{index + 1}. {item.name}
|
||||
{item.year ? ` (${item.year})` : ''}
|
||||
</Button>
|
||||
<div className="hero-pin" key={item.id}>
|
||||
<span className="hero-pin-order">{index + 1}</span>
|
||||
<span><b>{item.name}</b><small>{item.type}{item.year ? ` · ${item.year}` : ''}</small></span>
|
||||
<Button size="sm" icon="clock" onClick={() => setScheduleDraft(newSchedule(item, placement))}>
|
||||
Schedule
|
||||
</Button>
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
icon="close"
|
||||
title={`Remove ${item.name}`}
|
||||
onClick={() => setCurrent({ pinnedItems: pins.filter((pin) => pin.id !== item.id) })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -174,18 +227,77 @@ export function HeroPage() {
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
<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 = [...(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>
|
||||
<Card
|
||||
title="Hero schedule"
|
||||
intro="The gateway applies these rules in server time. Manual pins win first; otherwise the highest-priority active schedule wins, followed by Memby’s automatic hero."
|
||||
icon="clock"
|
||||
tone="info"
|
||||
actions={<Tag tone="info">{policy?.timeZone || 'server local time'}</Tag>}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
|
||||
Save schedule
|
||||
</Button>
|
||||
<span className="hint">Daily and weekly rules repeat until you switch them off.</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{schedules.length === 0 ? (
|
||||
<Empty>No scheduled heroes yet. Use Schedule beside a pinned or searched title.</Empty>
|
||||
) : (
|
||||
<div className="hero-schedule-list">
|
||||
{[...schedules]
|
||||
.sort((left, right) => Number(right.enabled) - Number(left.enabled) || right.priority - left.priority)
|
||||
.map((schedule) => {
|
||||
const item = [...(policy?.items ?? []), ...pins, ...(results ?? [])]
|
||||
.find((candidate) => candidate.id === schedule.itemId);
|
||||
return (
|
||||
<article className="hero-schedule" data-enabled={schedule.enabled || undefined} key={schedule.id}>
|
||||
<div className="hero-schedule-time">
|
||||
<b>{schedule.frequency === 'weekly' ? 'Weekly' : schedule.frequency === 'daily' ? 'Daily' : 'Once'}</b>
|
||||
<span>{schedule.frequency ? schedule.startTime : schedule.startAt ? new Date(schedule.startAt).toLocaleDateString() : '—'}</span>
|
||||
</div>
|
||||
<div className="hero-schedule-main">
|
||||
<div className="hero-schedule-title">
|
||||
<h3>{item?.name ?? schedule.itemId}</h3>
|
||||
<Tag tone={schedule.enabled ? 'ok' : undefined}>{schedule.enabled ? 'enabled' : 'paused'}</Tag>
|
||||
</div>
|
||||
<p>{scheduleSummary(schedule)}</p>
|
||||
<div className="chips">
|
||||
{(schedule.placements ?? ['home']).map((value) => (
|
||||
<span className="chip" key={value}>{PLACEMENTS.find((entry) => entry.id === value)?.label}</span>
|
||||
))}
|
||||
{schedule.priority !== 0 ? <span className="chip">Priority {schedule.priority}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-schedule-actions">
|
||||
<Button size="sm" onClick={() => setScheduleDraft({ ...schedule })}>Edit</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="quiet"
|
||||
onClick={() => {
|
||||
setSchedules((current) => current.map((entry) => entry.id === schedule.id ? { ...entry, enabled: !entry.enabled } : entry));
|
||||
setDirty(true);
|
||||
}}
|
||||
>
|
||||
{schedule.enabled ? 'Pause' : 'Enable'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="quiet"
|
||||
onClick={() => {
|
||||
setSchedules((current) => current.filter((entry) => entry.id !== schedule.id));
|
||||
setDirty(true);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</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, 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
|
||||
@@ -217,14 +329,19 @@ export function HeroPage() {
|
||||
<Grid>
|
||||
{results.map((item) => (
|
||||
<Card key={item.id} title={item.name} intro={`${item.type || 'Title'} · ${item.year || 'Year unknown'}`}>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="plus"
|
||||
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'}
|
||||
</Button>
|
||||
<div className="row">
|
||||
<Button
|
||||
size="sm"
|
||||
icon="plus"
|
||||
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'}
|
||||
</Button>
|
||||
<Button size="sm" icon="clock" onClick={() => setScheduleDraft(newSchedule(item, placement))}>
|
||||
Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
@@ -232,6 +349,197 @@ export function HeroPage() {
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{scheduleDraft ? (
|
||||
<ScheduleEditor
|
||||
schedule={scheduleDraft}
|
||||
item={[...(policy?.items ?? []), ...pins, ...(results ?? [])].find((candidate) => candidate.id === scheduleDraft.itemId)}
|
||||
timeZone={policy?.timeZone || 'server local time'}
|
||||
isNew={!schedules.some((entry) => entry.id === scheduleDraft.id)}
|
||||
onCancel={() => setScheduleDraft(null)}
|
||||
onSave={(next) => {
|
||||
setSchedules((current) => current.some((entry) => entry.id === next.id)
|
||||
? current.map((entry) => entry.id === next.id ? next : entry)
|
||||
: [...current, next]);
|
||||
setScheduleDraft(null);
|
||||
setDirty(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleEditor({
|
||||
schedule,
|
||||
item,
|
||||
timeZone,
|
||||
isNew,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
schedule: HeroSchedule;
|
||||
item?: HeroItem;
|
||||
timeZone: string;
|
||||
isNew: boolean;
|
||||
onSave: (schedule: HeroSchedule) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<HeroSchedule>({ ...schedule, weekdays: [...(schedule.weekdays ?? [])] });
|
||||
const frequency = scheduleFrequency(draft);
|
||||
const setFrequency = (next: ScheduleFrequency) => {
|
||||
const now = new Date();
|
||||
const later = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||
setDraft((current) => next === 'once'
|
||||
? {
|
||||
...current,
|
||||
frequency: undefined,
|
||||
startAt: usableAbsoluteTime(current.startAt) ? current.startAt : now.toISOString(),
|
||||
endAt: usableAbsoluteTime(current.endAt) ? current.endAt : later.toISOString(),
|
||||
}
|
||||
: {
|
||||
...current,
|
||||
frequency: next,
|
||||
startTime: current.startTime || '18:00',
|
||||
endTime: current.endTime || '22:00',
|
||||
weekdays: next === 'weekly' ? (current.weekdays?.length ? current.weekdays : [1, 2, 3, 4, 5]) : [],
|
||||
});
|
||||
};
|
||||
const selectedPlacements = draft.placements ?? ['home'];
|
||||
const validPlacement = (value: HeroPlacement) => value === 'home' ||
|
||||
(value === 'movies' && item?.type === 'Movie') ||
|
||||
(value === 'tv_shows' && item?.type === 'Series');
|
||||
const onceValid = Boolean(draft.startAt && draft.endAt && new Date(draft.endAt) > new Date(draft.startAt));
|
||||
const recurringValid = Boolean(draft.startTime && draft.endTime && draft.startTime !== draft.endTime &&
|
||||
(frequency !== 'weekly' || (draft.weekdays ?? []).length > 0));
|
||||
const valid = frequency === 'once' ? onceValid : recurringValid;
|
||||
|
||||
return (
|
||||
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
|
||||
<div className="dialog hero-schedule-dialog" role="dialog" aria-modal="true" aria-labelledby="hero-schedule-title">
|
||||
<div className="hero-schedule-dialog-head">
|
||||
<span className="hero-schedule-kicker">Hero schedule</span>
|
||||
<h2 id="hero-schedule-title">{item?.name ?? draft.itemId}</h2>
|
||||
<p>Choose exactly when this title can lead the selected sections. Times use {timeZone}.</p>
|
||||
</div>
|
||||
|
||||
<div className="schedule-frequency" role="group" aria-label="Schedule frequency">
|
||||
{(['once', 'daily', 'weekly'] as ScheduleFrequency[]).map((value) => (
|
||||
<button key={value} type="button" aria-pressed={frequency === value} onClick={() => setFrequency(value)}>
|
||||
<b>{value === 'once' ? 'One time' : value === 'daily' ? 'Every day' : 'Weekly'}</b>
|
||||
<span>{value === 'once' ? 'A date range' : value === 'daily' ? 'Same time daily' : 'Choose days'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{frequency === 'once' ? (
|
||||
<div className="fields">
|
||||
<Field label="Starts">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={draft.startAt ? localDateTime(new Date(draft.startAt)) : ''}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, startAt: event.target.value ? new Date(event.target.value).toISOString() : undefined }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Ends">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={draft.endAt ? localDateTime(new Date(draft.endAt)) : ''}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, endAt: event.target.value ? new Date(event.target.value).toISOString() : undefined }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="fields">
|
||||
<Field label="Starts each time">
|
||||
<input type="time" value={draft.startTime ?? ''} onChange={(event) => setDraft((current) => ({ ...current, startTime: event.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Ends each time" hint="An earlier end time continues into the following day.">
|
||||
<input type="time" value={draft.endTime ?? ''} onChange={(event) => setDraft((current) => ({ ...current, endTime: event.target.value }))} />
|
||||
</Field>
|
||||
</div>
|
||||
{frequency === 'weekly' ? (
|
||||
<div className="schedule-days">
|
||||
<div className="schedule-days-head">
|
||||
<b>Days</b>
|
||||
<div>
|
||||
<button type="button" onClick={() => setDraft((current) => ({ ...current, weekdays: [1, 2, 3, 4, 5] }))}>Weekdays</button>
|
||||
<button type="button" onClick={() => setDraft((current) => ({ ...current, weekdays: [6, 0] }))}>Weekend</button>
|
||||
<button type="button" onClick={() => setDraft((current) => ({ ...current, weekdays: WEEKDAYS.map((day) => day.value) }))}>Every day</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="schedule-day-grid">
|
||||
{WEEKDAYS.map((day) => {
|
||||
const selected = (draft.weekdays ?? []).includes(day.value);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={day.value}
|
||||
aria-pressed={selected}
|
||||
title={day.label}
|
||||
onClick={() => setDraft((current) => ({
|
||||
...current,
|
||||
weekdays: selected
|
||||
? (current.weekdays ?? []).filter((value) => value !== day.value)
|
||||
: [...(current.weekdays ?? []), day.value],
|
||||
}))}
|
||||
>
|
||||
{day.short}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="schedule-options">
|
||||
<div>
|
||||
<span className="schedule-option-label">Show in</span>
|
||||
<div className="schedule-placement-grid">
|
||||
{PLACEMENTS.map((option) => (
|
||||
<Toggle
|
||||
key={option.id}
|
||||
label={option.label}
|
||||
checked={selectedPlacements.includes(option.id)}
|
||||
disabled={!validPlacement(option.id)}
|
||||
onChange={(enabled) => setDraft((current) => {
|
||||
const selected = current.placements ?? ['home'];
|
||||
const next = enabled ? [...selected, option.id] : selected.filter((value) => value !== option.id);
|
||||
return { ...current, placements: next.length ? [...new Set(next)] : selected };
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Priority" hint="Higher rules win when schedules overlap.">
|
||||
<input
|
||||
type="number"
|
||||
min={-1000}
|
||||
max={1000}
|
||||
step={10}
|
||||
value={draft.priority}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, priority: Number(event.target.value) }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
label="Schedule enabled"
|
||||
hint="Pause it without losing its days and times."
|
||||
checked={draft.enabled}
|
||||
onChange={(enabled) => setDraft((current) => ({ ...current, enabled }))}
|
||||
/>
|
||||
|
||||
<div className="dialog-actions">
|
||||
<Button variant="quiet" onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="primary" disabled={!valid} onClick={() => onSave(draft)}>
|
||||
{isNew ? 'Add rule' : 'Save rule'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user