frontend: align types to backend, playout HLS, rm Jellyfin proxy (#12)
This commit is contained in:
@@ -12,12 +12,8 @@ interface AccessSettingsEditorProps {
|
||||
|
||||
export function AccessSettingsEditor({
|
||||
accessMode,
|
||||
accessPassword,
|
||||
onAccessModeChange,
|
||||
onAccessPasswordChange,
|
||||
label = "Access",
|
||||
passwordLabel = "Password",
|
||||
passwordHint = "Leave blank to keep existing password",
|
||||
}: AccessSettingsEditorProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -25,33 +21,13 @@ export function AccessSettingsEditor({
|
||||
<label className="block text-xs font-medium text-zinc-400">{label}</label>
|
||||
<select
|
||||
value={accessMode}
|
||||
onChange={(e) => {
|
||||
onAccessModeChange(e.target.value as AccessMode);
|
||||
onAccessPasswordChange("");
|
||||
}}
|
||||
onChange={(e) => onAccessModeChange(e.target.value as AccessMode)}
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none"
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="password_protected">Password protected</option>
|
||||
<option value="account_required">Account required</option>
|
||||
<option value="owner_only">Owner only</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{accessMode === "password_protected" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-zinc-400">
|
||||
{passwordLabel}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={passwordHint}
|
||||
value={accessPassword}
|
||||
onChange={(e) => onAccessPasswordChange(e.target.value)}
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,6 +176,9 @@ export function AlgorithmicFilterEditor({
|
||||
<option value="random">Random</option>
|
||||
<option value="best_fit">Best fit</option>
|
||||
<option value="sequential">Sequential</option>
|
||||
<option value="alternating">Alternating</option>
|
||||
<option value="weighted">Weighted</option>
|
||||
<option value="marathon">Marathon</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,6 @@ interface CreateChannelDialogProps {
|
||||
timezone: string;
|
||||
description: string;
|
||||
access_mode?: AccessMode;
|
||||
access_password?: string;
|
||||
}) => void;
|
||||
isPending: boolean;
|
||||
error?: string | null;
|
||||
@@ -36,7 +35,6 @@ export function CreateChannelDialog({
|
||||
const [timezone, setTimezone] = useState("UTC");
|
||||
const [description, setDescription] = useState("");
|
||||
const [accessMode, setAccessMode] = useState<AccessMode>("public");
|
||||
const [accessPassword, setAccessPassword] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -45,7 +43,6 @@ export function CreateChannelDialog({
|
||||
timezone,
|
||||
description,
|
||||
access_mode: accessMode !== "public" ? accessMode : undefined,
|
||||
access_password: accessMode === "password_protected" && accessPassword ? accessPassword : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -57,7 +54,6 @@ export function CreateChannelDialog({
|
||||
setTimezone("UTC");
|
||||
setDescription("");
|
||||
setAccessMode("public");
|
||||
setAccessPassword("");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -120,25 +116,10 @@ export function CreateChannelDialog({
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none"
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="password_protected">Password protected</option>
|
||||
<option value="account_required">Account required</option>
|
||||
<option value="owner_only">Owner only</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{accessMode === "password_protected" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-zinc-400">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={accessPassword}
|
||||
onChange={(e) => setAccessPassword(e.target.value)}
|
||||
placeholder="Channel password"
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
@@ -151,7 +132,7 @@ export function CreateChannelDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Creating…" : "Create channel"}
|
||||
{isPending ? "Creating..." : "Create channel"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BlockTimeline, BLOCK_COLORS } from "./block-timeline";
|
||||
import { AlgorithmicFilterEditor } from "./algorithmic-filter-editor";
|
||||
import { RecyclePolicyEditor } from "./recycle-policy-editor";
|
||||
import { RotationPolicyEditor } from "./rotation-policy-editor";
|
||||
import { WebhookEditor } from "./webhook-editor";
|
||||
import { AccessSettingsEditor } from "./access-settings-editor";
|
||||
import { LogoEditor } from "./logo-editor";
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
FillStrategy,
|
||||
MediaFilter,
|
||||
ProviderInfo,
|
||||
RecyclePolicy,
|
||||
RotationPolicy,
|
||||
Weekday,
|
||||
} from "@/lib/types";
|
||||
import { WEEKDAYS, WEEKDAY_LABELS } from "@/lib/types";
|
||||
@@ -261,9 +261,9 @@ function BlockEditor({ block, index, errors, providers, onChange }: BlockEditorP
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={block.ignore_recycle_policy ?? false}
|
||||
checked={block.ignore_rotation_policy ?? false}
|
||||
onChange={(e) =>
|
||||
onChange({ ...block, ignore_recycle_policy: e.target.checked })
|
||||
onChange({ ...block, ignore_rotation_policy: e.target.checked })
|
||||
}
|
||||
className="accent-zinc-400"
|
||||
/>
|
||||
@@ -301,7 +301,7 @@ function BlockEditor({ block, index, errors, providers, onChange }: BlockEditorP
|
||||
className="w-full resize-none rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 font-mono text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
|
||||
/>
|
||||
<p className="text-[11px] text-zinc-600">
|
||||
One Jellyfin item ID per line, played in order.
|
||||
One item ID per line, played in order.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -339,12 +339,11 @@ interface EditChannelSheetProps {
|
||||
description: string;
|
||||
timezone: string;
|
||||
schedule_config: { day_blocks: Record<Weekday, ProgrammingBlock[]> };
|
||||
recycle_policy: RecyclePolicy;
|
||||
rotation_policy: RotationPolicy;
|
||||
auto_schedule: boolean;
|
||||
access_mode?: AccessMode;
|
||||
access_password?: string;
|
||||
access_mode?: string;
|
||||
logo?: string | null;
|
||||
logo_position?: LogoPosition;
|
||||
logo_position?: string;
|
||||
logo_opacity?: number;
|
||||
webhook_url?: string | null;
|
||||
webhook_poll_interval_secs?: number;
|
||||
@@ -401,7 +400,7 @@ export function EditChannelSheet({
|
||||
description: form.description,
|
||||
timezone: form.timezone,
|
||||
day_blocks: form.dayBlocks,
|
||||
recycle_policy: form.recyclePolicy,
|
||||
rotation_policy: form.rotationPolicy,
|
||||
auto_schedule: form.autoSchedule,
|
||||
access_mode: form.accessMode,
|
||||
access_password: form.accessPassword,
|
||||
@@ -418,10 +417,9 @@ export function EditChannelSheet({
|
||||
description: form.description,
|
||||
timezone: form.timezone,
|
||||
schedule_config: { day_blocks: form.dayBlocks },
|
||||
recycle_policy: form.recyclePolicy,
|
||||
rotation_policy: form.rotationPolicy,
|
||||
auto_schedule: form.autoSchedule,
|
||||
access_mode: form.accessMode !== "public" ? form.accessMode : "public",
|
||||
access_password: form.accessPassword || "",
|
||||
access_mode: form.accessMode,
|
||||
logo: form.logo,
|
||||
logo_position: form.logoPosition,
|
||||
logo_opacity: form.logoOpacity / 100,
|
||||
@@ -540,12 +538,12 @@ export function EditChannelSheet({
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Recycle policy
|
||||
Rotation policy
|
||||
</h3>
|
||||
<RecyclePolicyEditor
|
||||
policy={form.recyclePolicy}
|
||||
<RotationPolicyEditor
|
||||
policy={form.rotationPolicy}
|
||||
errors={fieldErrors}
|
||||
onChange={form.setRecyclePolicy}
|
||||
onChange={form.setRotationPolicy}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ProgrammingBlock, RecyclePolicy } from "@/lib/types";
|
||||
import type { ProgrammingBlock, RotationPolicy } from "@/lib/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import schema — lenient so LLM output and community exports both work
|
||||
@@ -37,7 +37,7 @@ const importBlockSchema = z.object({
|
||||
max_duration_secs: z.number().nullable().optional(),
|
||||
collections: z.array(z.string()).default([]),
|
||||
}),
|
||||
strategy: z.enum(["best_fit", "sequential", "random"]).default("random"),
|
||||
strategy: z.enum(["best_fit", "sequential", "random", "alternating", "weighted", "marathon"]).default("random"),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("manual"),
|
||||
@@ -46,7 +46,7 @@ const importBlockSchema = z.object({
|
||||
]),
|
||||
});
|
||||
|
||||
const recyclePolicySchema = z
|
||||
const rotationPolicySchema = z
|
||||
.object({
|
||||
cooldown_days: z.number().int().min(0).nullable().optional(),
|
||||
cooldown_generations: z.number().int().min(0).nullable().optional(),
|
||||
@@ -62,7 +62,7 @@ const importSchema = z
|
||||
timezone: z.string().default("UTC"),
|
||||
blocks: z.array(importBlockSchema).optional(),
|
||||
schedule_config: z.object({ blocks: z.array(importBlockSchema).optional() }).optional(),
|
||||
recycle_policy: recyclePolicySchema,
|
||||
rotation_policy: rotationPolicySchema,
|
||||
})
|
||||
.transform((d) => ({
|
||||
name: d.name,
|
||||
@@ -72,7 +72,7 @@ const importSchema = z
|
||||
...b,
|
||||
id: b.id ?? crypto.randomUUID(),
|
||||
})) as ProgrammingBlock[],
|
||||
recycle_policy: d.recycle_policy as RecyclePolicy,
|
||||
rotation_policy: d.rotation_policy as RotationPolicy,
|
||||
}));
|
||||
|
||||
export type ChannelImportData = z.output<typeof importSchema>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RecyclePolicy } from "@/lib/types";
|
||||
import type { RotationPolicy } from "@/lib/types";
|
||||
import type { FieldErrors } from "@/lib/schemas";
|
||||
|
||||
function NumberInput({
|
||||
@@ -58,17 +58,17 @@ function Field({
|
||||
);
|
||||
}
|
||||
|
||||
interface RecyclePolicyEditorProps {
|
||||
policy: RecyclePolicy;
|
||||
interface RotationPolicyEditorProps {
|
||||
policy: RotationPolicy;
|
||||
errors: FieldErrors;
|
||||
onChange: (policy: RecyclePolicy) => void;
|
||||
onChange: (policy: RotationPolicy) => void;
|
||||
}
|
||||
|
||||
export function RecyclePolicyEditor({
|
||||
export function RotationPolicyEditor({
|
||||
policy,
|
||||
errors,
|
||||
onChange,
|
||||
}: RecyclePolicyEditorProps) {
|
||||
}: RotationPolicyEditorProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -96,7 +96,7 @@ export function RecyclePolicyEditor({
|
||||
<Field
|
||||
label="Min available ratio"
|
||||
hint="0.0–1.0 · Fraction of the pool kept selectable even if cooldown is active"
|
||||
error={errors["recycle_policy.min_available_ratio"]}
|
||||
error={errors["rotation_policy.min_available_ratio"]}
|
||||
>
|
||||
<NumberInput
|
||||
value={policy.min_available_ratio}
|
||||
@@ -107,7 +107,7 @@ export function RecyclePolicyEditor({
|
||||
max={1}
|
||||
step={0.01}
|
||||
placeholder="0.1"
|
||||
error={!!errors["recycle_policy.min_available_ratio"]}
|
||||
error={!!errors["rotation_policy.min_available_ratio"]}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
@@ -6,12 +6,12 @@ import { useActiveSchedule } from "@/hooks/use-channels";
|
||||
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
|
||||
import { BLOCK_COLORS } from "./block-timeline";
|
||||
|
||||
// Stable color per block_id within a schedule
|
||||
// Stable color per source_block_id
|
||||
function makeColorMap(slots: ScheduledSlotResponse[]): Map<string, string> {
|
||||
const seen = new Map<string, string>();
|
||||
slots.forEach((slot) => {
|
||||
if (!seen.has(slot.block_id)) {
|
||||
seen.set(slot.block_id, BLOCK_COLORS[seen.size % BLOCK_COLORS.length]);
|
||||
if (!seen.has(slot.source_block_id)) {
|
||||
seen.set(slot.source_block_id, BLOCK_COLORS[seen.size % BLOCK_COLORS.length]);
|
||||
}
|
||||
});
|
||||
return seen;
|
||||
@@ -66,7 +66,7 @@ function DayRow({ label, dayStart, slots, colorMap, now }: DayRowProps) {
|
||||
const clampedEnd = Math.min(slotEnd.getTime(), dayEnd.getTime());
|
||||
const leftPct = ((clampedStart - dayStart.getTime()) / DAY_MS) * 100;
|
||||
const widthPct = ((clampedEnd - clampedStart) / DAY_MS) * 100;
|
||||
const color = colorMap.get(slot.block_id) ?? "#6b7280";
|
||||
const color = colorMap.get(slot.source_block_id) ?? "#6b7280";
|
||||
|
||||
const startTime = slotStart.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
const endTime = slotEnd.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
@@ -201,7 +201,7 @@ export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProp
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Slots</h3>
|
||||
<div className="rounded-md border border-zinc-800 divide-y divide-zinc-800">
|
||||
{schedule.slots.map((slot) => {
|
||||
const color = colorMap.get(slot.block_id) ?? "#6b7280";
|
||||
const color = colorMap.get(slot.source_block_id) ?? "#6b7280";
|
||||
const start = new Date(slot.start_at).toLocaleString(undefined, {
|
||||
weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user