- Removed unnecessary class names for buttons in ChannelCard and DashboardPage components. - Updated layout styles in RootLayout to apply dark mode by default. - Refactored edit-channel-sheet to streamline block editor and filter editor components. - Adjusted duration input fields to reflect minutes instead of seconds in AlgorithmicFilterEditor. - Enhanced the structure of the EditChannelSheet for better readability and maintainability.
1245 lines
47 KiB
TypeScript
1245 lines
47 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef } from "react";
|
||
import { z } from "zod";
|
||
import { Trash2, Plus } from "lucide-react";
|
||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||
import { Button } from "@/components/ui/button";
|
||
import { TagInput } from "./tag-input";
|
||
import { BlockTimeline, BLOCK_COLORS, minsToTime } from "./block-timeline";
|
||
import { SeriesPicker } from "./series-picker";
|
||
import { FilterPreview } from "./filter-preview";
|
||
import { useCollections, useSeries, useGenres } from "@/hooks/use-library";
|
||
import type {
|
||
AccessMode,
|
||
ChannelResponse,
|
||
LogoPosition,
|
||
ProgrammingBlock,
|
||
BlockContent,
|
||
FillStrategy,
|
||
ContentType,
|
||
MediaFilter,
|
||
ProviderInfo,
|
||
RecyclePolicy,
|
||
} from "@/lib/types";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Webhook preset templates (frontend-only, zero backend changes needed)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const WEBHOOK_PRESETS = {
|
||
discord: `{
|
||
"embeds": [{
|
||
"title": "📺 {{event}}",
|
||
"description": "{{#if data.item.title}}Now playing: **{{data.item.title}}**{{else}}No signal{{/if}}",
|
||
"color": 3447003,
|
||
"timestamp": "{{timestamp}}"
|
||
}]
|
||
}`,
|
||
slack: `{
|
||
"text": "📺 *{{event}}*{{#if data.item.title}} — {{data.item.title}}{{/if}}"
|
||
}`,
|
||
} as const;
|
||
|
||
type WebhookFormat = "default" | "discord" | "slack" | "custom";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Zod schemas
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const mediaFilterSchema = z.object({
|
||
content_type: z.enum(["movie", "episode", "short"]).nullable().optional(),
|
||
genres: z.array(z.string()),
|
||
decade: z
|
||
.number()
|
||
.int()
|
||
.min(1900, "Decade must be ≥ 1900")
|
||
.max(2099, "Decade must be ≤ 2090")
|
||
.nullable()
|
||
.optional(),
|
||
tags: z.array(z.string()),
|
||
min_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(),
|
||
max_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(),
|
||
collections: z.array(z.string()),
|
||
series_names: z.array(z.string()),
|
||
search_term: z.string().nullable().optional(),
|
||
});
|
||
|
||
const accessModeSchema = z.enum(["public", "password_protected", "account_required", "owner_only"]);
|
||
|
||
const blockSchema = z.object({
|
||
id: z.string(),
|
||
name: z.string().min(1, "Block name is required"),
|
||
start_time: z.string(),
|
||
duration_mins: z.number().int().min(1, "Must be at least 1 minute"),
|
||
content: z.discriminatedUnion("type", [
|
||
z.object({
|
||
type: z.literal("algorithmic"),
|
||
filter: mediaFilterSchema,
|
||
strategy: z.enum(["best_fit", "sequential", "random"]),
|
||
provider_id: z.string().optional(),
|
||
}),
|
||
z.object({
|
||
type: z.literal("manual"),
|
||
items: z.array(z.string()),
|
||
provider_id: z.string().optional(),
|
||
}),
|
||
]),
|
||
loop_on_finish: z.boolean().optional(),
|
||
ignore_recycle_policy: z.boolean().optional(),
|
||
access_mode: accessModeSchema.optional(),
|
||
access_password: z.string().optional(),
|
||
});
|
||
|
||
const channelFormSchema = z.object({
|
||
name: z.string().min(1, "Name is required"),
|
||
timezone: z.string().min(1, "Timezone is required"),
|
||
description: z.string().optional(),
|
||
blocks: z.array(blockSchema),
|
||
recycle_policy: z.object({
|
||
cooldown_days: z.number().int().min(0).nullable().optional(),
|
||
cooldown_generations: z.number().int().min(0).nullable().optional(),
|
||
min_available_ratio: z.number().min(0, "Must be ≥ 0").max(1, "Must be ≤ 1"),
|
||
}),
|
||
auto_schedule: z.boolean(),
|
||
access_mode: accessModeSchema.optional(),
|
||
access_password: z.string().optional(),
|
||
});
|
||
|
||
type FieldErrors = Record<string, string | undefined>;
|
||
|
||
function extractErrors(err: z.ZodError): FieldErrors {
|
||
const map: FieldErrors = {};
|
||
for (const issue of err.issues) {
|
||
const key = issue.path.join(".");
|
||
if (!map[key]) map[key] = issue.message;
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Field wrapper
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function Field({
|
||
label,
|
||
hint,
|
||
error,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
hint?: string;
|
||
error?: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="space-y-1.5">
|
||
<label className="block text-xs font-medium text-zinc-400">{label}</label>
|
||
{children}
|
||
{error ? (
|
||
<p className="text-[11px] text-red-400">{error}</p>
|
||
) : hint ? (
|
||
<p className="text-[11px] text-zinc-600">{hint}</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TextInput({
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
required,
|
||
error,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
required?: boolean;
|
||
error?: boolean;
|
||
}) {
|
||
return (
|
||
<input
|
||
required={required}
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
className={`w-full rounded-md border bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:outline-none ${error ? "border-red-500 focus:border-red-400" : "border-zinc-700 focus:border-zinc-500"}`}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function NumberInput({
|
||
value,
|
||
onChange,
|
||
min,
|
||
max,
|
||
step,
|
||
placeholder,
|
||
error,
|
||
}: {
|
||
value: number | "";
|
||
onChange: (v: number | "") => void;
|
||
min?: number;
|
||
max?: number;
|
||
step?: number | "any";
|
||
placeholder?: string;
|
||
error?: boolean;
|
||
}) {
|
||
return (
|
||
<input
|
||
type="number"
|
||
min={min}
|
||
max={max}
|
||
step={step}
|
||
value={value}
|
||
placeholder={placeholder}
|
||
onChange={(e) => onChange(e.target.value === "" ? "" : Number(e.target.value))}
|
||
className={`w-full rounded-md border bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:outline-none ${error ? "border-red-500 focus:border-red-400" : "border-zinc-700 focus:border-zinc-500"}`}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function NativeSelect({
|
||
value,
|
||
onChange,
|
||
children,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<select
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
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"
|
||
>
|
||
{children}
|
||
</select>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Defaults
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function defaultFilter(): MediaFilter {
|
||
return {
|
||
content_type: null,
|
||
genres: [],
|
||
decade: null,
|
||
tags: [],
|
||
min_duration_secs: null,
|
||
max_duration_secs: null,
|
||
collections: [],
|
||
series_names: [],
|
||
search_term: null,
|
||
};
|
||
}
|
||
|
||
function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock {
|
||
return {
|
||
id: crypto.randomUUID(),
|
||
name: "",
|
||
start_time: minsToTime(startMins),
|
||
duration_mins: durationMins,
|
||
content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" },
|
||
loop_on_finish: true,
|
||
ignore_recycle_policy: false,
|
||
access_mode: "public",
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AlgorithmicFilterEditor — filter section with live library data
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface AlgorithmicFilterEditorProps {
|
||
content: Extract<BlockContent, { type: "algorithmic" }>;
|
||
pfx: string;
|
||
errors: FieldErrors;
|
||
setFilter: (patch: Partial<MediaFilter>) => void;
|
||
setStrategy: (strategy: FillStrategy) => void;
|
||
setProviderId: (id: string) => void;
|
||
providers: ProviderInfo[];
|
||
}
|
||
|
||
function AlgorithmicFilterEditor({
|
||
content,
|
||
pfx,
|
||
errors,
|
||
setFilter,
|
||
setStrategy,
|
||
setProviderId,
|
||
providers,
|
||
}: AlgorithmicFilterEditorProps) {
|
||
const [showGenres, setShowGenres] = useState(false);
|
||
|
||
const providerId = content.provider_id ?? "";
|
||
const capabilities = providers.find((p) => p.id === providerId)?.capabilities
|
||
?? providers[0]?.capabilities;
|
||
|
||
const { data: collections, isLoading: loadingCollections } = useCollections(providerId || undefined);
|
||
const { data: series, isLoading: loadingSeries } = useSeries(undefined, {
|
||
enabled: capabilities?.series !== false,
|
||
provider: providerId || undefined,
|
||
});
|
||
const { data: genreOptions } = useGenres(content.filter.content_type ?? undefined, {
|
||
enabled: capabilities?.genres !== false,
|
||
provider: providerId || undefined,
|
||
});
|
||
|
||
const isEpisode = content.filter.content_type === "episode";
|
||
const collectionLabel =
|
||
capabilities?.collections && !capabilities?.series && !capabilities?.genres
|
||
? "Directory"
|
||
: "Library";
|
||
|
||
return (
|
||
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Filter</p>
|
||
|
||
{providers.length > 1 && (
|
||
<Field label="Provider">
|
||
<NativeSelect value={providerId} onChange={(v) => setProviderId(v)}>
|
||
{providers.map((p) => (
|
||
<option key={p.id} value={p.id}>{p.id}</option>
|
||
))}
|
||
</NativeSelect>
|
||
</Field>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Media type">
|
||
<NativeSelect
|
||
value={content.filter.content_type ?? ""}
|
||
onChange={(v) =>
|
||
setFilter({
|
||
content_type: v === "" ? null : (v as ContentType),
|
||
// clear series names if switching away from episode
|
||
series_names: v !== "episode" ? [] : content.filter.series_names,
|
||
})
|
||
}
|
||
>
|
||
<option value="">Any</option>
|
||
<option value="movie">Movie</option>
|
||
<option value="episode">Episode</option>
|
||
<option value="short">Short</option>
|
||
</NativeSelect>
|
||
</Field>
|
||
<Field label="Strategy">
|
||
<NativeSelect
|
||
value={content.strategy}
|
||
onChange={(v) => setStrategy(v as FillStrategy)}
|
||
>
|
||
<option value="random">Random</option>
|
||
<option value="best_fit">Best fit</option>
|
||
<option value="sequential">Sequential</option>
|
||
</NativeSelect>
|
||
</Field>
|
||
</div>
|
||
|
||
{/* Series — only meaningful for episodes when provider supports it */}
|
||
{isEpisode && capabilities?.series !== false && (
|
||
<Field
|
||
label="Series"
|
||
hint={
|
||
content.strategy === "sequential"
|
||
? "Episodes will play in chronological order"
|
||
: "Filter to specific shows, or leave empty for all"
|
||
}
|
||
>
|
||
<SeriesPicker
|
||
values={content.filter.series_names ?? []}
|
||
onChange={(v) => setFilter({ series_names: v })}
|
||
series={series ?? []}
|
||
isLoading={loadingSeries}
|
||
/>
|
||
</Field>
|
||
)}
|
||
|
||
{/* Library/Directory — real collection names when the provider supports it */}
|
||
<Field
|
||
label={collectionLabel}
|
||
hint={
|
||
loadingCollections
|
||
? `Loading ${collectionLabel.toLowerCase()}s…`
|
||
: collections
|
||
? `Scope this block to one ${collectionLabel.toLowerCase()}`
|
||
: `Enter a provider ${collectionLabel.toLowerCase()} ID`
|
||
}
|
||
>
|
||
{collections && collections.length > 0 ? (
|
||
<NativeSelect
|
||
value={content.filter.collections[0] ?? ""}
|
||
onChange={(v) => setFilter({ collections: v ? [v] : [] })}
|
||
>
|
||
<option value="">All libraries</option>
|
||
{collections.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.name}
|
||
{c.collection_type ? ` (${c.collection_type})` : ""}
|
||
</option>
|
||
))}
|
||
</NativeSelect>
|
||
) : (
|
||
<TagInput
|
||
values={content.filter.collections}
|
||
onChange={(v) => setFilter({ collections: v })}
|
||
placeholder="Library ID…"
|
||
/>
|
||
)}
|
||
</Field>
|
||
|
||
{/* Genres — only shown when provider supports it */}
|
||
{capabilities?.genres !== false && (
|
||
<Field label="Genres" hint="Press Enter or comma to add">
|
||
<TagInput
|
||
values={content.filter.genres}
|
||
onChange={(v) => setFilter({ genres: v })}
|
||
placeholder="Comedy, Animation…"
|
||
/>
|
||
{genreOptions && genreOptions.length > 0 && (
|
||
<div className="mt-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowGenres((s) => !s)}
|
||
className="text-[11px] text-zinc-600 hover:text-zinc-400"
|
||
>
|
||
{showGenres ? "Hide" : "Browse"} available genres
|
||
</button>
|
||
{showGenres && (
|
||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||
{genreOptions
|
||
.filter((g) => !content.filter.genres.includes(g))
|
||
.map((g) => (
|
||
<button
|
||
key={g}
|
||
type="button"
|
||
onClick={() => setFilter({ genres: [...content.filter.genres, g] })}
|
||
className="rounded px-1.5 py-0.5 text-[11px] bg-zinc-700/50 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||
>
|
||
+ {g}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Field>
|
||
)}
|
||
|
||
<Field label="Tags" hint="Press Enter or comma to add">
|
||
<TagInput
|
||
values={content.filter.tags}
|
||
onChange={(v) => setFilter({ tags: v })}
|
||
placeholder="classic, family…"
|
||
/>
|
||
</Field>
|
||
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<Field label="Decade" hint="e.g. 1990" error={errors[`${pfx}.content.filter.decade`]}>
|
||
<NumberInput
|
||
value={content.filter.decade ?? ""}
|
||
onChange={(v) => setFilter({ decade: v === "" ? null : (v as number) })}
|
||
placeholder="1990"
|
||
error={!!errors[`${pfx}.content.filter.decade`]}
|
||
/>
|
||
</Field>
|
||
<Field label="Min duration (min)" error={errors[`${pfx}.content.filter.min_duration_secs`]}>
|
||
<NumberInput
|
||
value={content.filter.min_duration_secs != null ? Math.round(content.filter.min_duration_secs / 60) : ""}
|
||
onChange={(v) =>
|
||
setFilter({ min_duration_secs: v === "" ? null : (v as number) * 60 })
|
||
}
|
||
placeholder="30"
|
||
error={!!errors[`${pfx}.content.filter.min_duration_secs`]}
|
||
/>
|
||
</Field>
|
||
<Field label="Max duration (min)" error={errors[`${pfx}.content.filter.max_duration_secs`]}>
|
||
<NumberInput
|
||
value={content.filter.max_duration_secs != null ? Math.round(content.filter.max_duration_secs / 60) : ""}
|
||
onChange={(v) =>
|
||
setFilter({ max_duration_secs: v === "" ? null : (v as number) * 60 })
|
||
}
|
||
placeholder="120"
|
||
error={!!errors[`${pfx}.content.filter.max_duration_secs`]}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
{/* Preview — snapshot of current filter+strategy, only fetches on explicit click */}
|
||
<FilterPreview filter={content.filter} strategy={content.strategy} provider={providerId || undefined} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// BlockEditor (detail form for a single block)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface BlockEditorProps {
|
||
block: ProgrammingBlock;
|
||
index: number;
|
||
errors: FieldErrors;
|
||
onChange: (block: ProgrammingBlock) => void;
|
||
providers: ProviderInfo[];
|
||
}
|
||
|
||
function BlockEditor({ block, index, errors, onChange, providers }: BlockEditorProps) {
|
||
const setField = <K extends keyof ProgrammingBlock>(key: K, value: ProgrammingBlock[K]) =>
|
||
onChange({ ...block, [key]: value });
|
||
|
||
const content = block.content;
|
||
const pfx = `blocks.${index}`;
|
||
|
||
const setContentType = (type: "algorithmic" | "manual") => {
|
||
const pid = content.provider_id ?? "";
|
||
onChange({
|
||
...block,
|
||
content:
|
||
type === "algorithmic"
|
||
? { type: "algorithmic", filter: defaultFilter(), strategy: "random", provider_id: pid }
|
||
: { type: "manual", items: [], provider_id: pid },
|
||
});
|
||
};
|
||
|
||
const setFilter = (patch: Partial<MediaFilter>) => {
|
||
if (content.type !== "algorithmic") return;
|
||
onChange({ ...block, content: { ...content, filter: { ...content.filter, ...patch } } });
|
||
};
|
||
|
||
const setStrategy = (strategy: FillStrategy) => {
|
||
if (content.type !== "algorithmic") return;
|
||
onChange({ ...block, content: { ...content, strategy } });
|
||
};
|
||
|
||
const setProviderId = (id: string) => {
|
||
onChange({ ...block, content: { ...content, provider_id: id } });
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Block name" error={errors[`${pfx}.name`]}>
|
||
<TextInput
|
||
value={block.name}
|
||
onChange={(v) => setField("name", v)}
|
||
placeholder="Evening Sitcoms"
|
||
error={!!errors[`${pfx}.name`]}
|
||
/>
|
||
</Field>
|
||
<Field label="Content type">
|
||
<NativeSelect
|
||
value={content.type}
|
||
onChange={(v) => setContentType(v as "algorithmic" | "manual")}
|
||
>
|
||
<option value="algorithmic">Algorithmic</option>
|
||
<option value="manual">Manual</option>
|
||
</NativeSelect>
|
||
</Field>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Start time">
|
||
<input
|
||
type="time"
|
||
value={block.start_time.slice(0, 5)}
|
||
onChange={(e) => setField("start_time", e.target.value + ":00")}
|
||
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"
|
||
/>
|
||
</Field>
|
||
<Field label="Duration (minutes)" error={errors[`${pfx}.duration_mins`]}>
|
||
<NumberInput
|
||
value={block.duration_mins}
|
||
onChange={(v) => setField("duration_mins", v === "" ? 60 : v)}
|
||
min={1}
|
||
error={!!errors[`${pfx}.duration_mins`]}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
{content.type === "algorithmic" && (
|
||
<>
|
||
<AlgorithmicFilterEditor
|
||
content={content}
|
||
pfx={pfx}
|
||
errors={errors}
|
||
setFilter={setFilter}
|
||
setStrategy={setStrategy}
|
||
setProviderId={setProviderId}
|
||
providers={providers}
|
||
/>
|
||
|
||
{content.strategy === "sequential" && (
|
||
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||
Sequential options
|
||
</p>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={block.loop_on_finish ?? true}
|
||
onChange={(e) => onChange({ ...block, loop_on_finish: e.target.checked })}
|
||
className="accent-zinc-400"
|
||
/>
|
||
<span className="text-sm text-zinc-300">Loop series</span>
|
||
<span className="text-[11px] text-zinc-600">
|
||
Restart from episode 1 after the final episode
|
||
</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={block.ignore_recycle_policy ?? false}
|
||
onChange={(e) => onChange({ ...block, ignore_recycle_policy: e.target.checked })}
|
||
className="accent-zinc-400"
|
||
/>
|
||
<span className="text-sm text-zinc-300">Independent scheduling</span>
|
||
<span className="text-[11px] text-zinc-600">
|
||
Play episodes in order even if they aired in another block today
|
||
</span>
|
||
</label>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{content.type === "manual" && (
|
||
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Item IDs</p>
|
||
<textarea
|
||
rows={3}
|
||
value={content.items.join("\n")}
|
||
onChange={(e) =>
|
||
onChange({
|
||
...block,
|
||
content: {
|
||
type: "manual",
|
||
items: e.target.value.split("\n").map((s) => s.trim()).filter(Boolean),
|
||
},
|
||
})
|
||
}
|
||
placeholder={"abc123\ndef456\nghi789"}
|
||
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.</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Block-level access control */}
|
||
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Block access</p>
|
||
<NativeSelect
|
||
value={block.access_mode ?? "public"}
|
||
onChange={(v) => onChange({ ...block, access_mode: v as AccessMode })}
|
||
>
|
||
<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>
|
||
</NativeSelect>
|
||
{(block.access_mode === "password_protected") && (
|
||
<input
|
||
type="password"
|
||
placeholder="Block password (leave blank to keep existing)"
|
||
value={block.access_password ?? ""}
|
||
onChange={(e) => onChange({ ...block, access_password: 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>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Recycle policy editor
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function RecyclePolicyEditor({
|
||
policy,
|
||
errors,
|
||
onChange,
|
||
}: {
|
||
policy: RecyclePolicy;
|
||
errors: FieldErrors;
|
||
onChange: (policy: RecyclePolicy) => void;
|
||
}) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Cooldown (days)" hint="Don't replay within N days">
|
||
<NumberInput
|
||
value={policy.cooldown_days ?? ""}
|
||
onChange={(v) => onChange({ ...policy, cooldown_days: v === "" ? null : (v as number) })}
|
||
min={0}
|
||
placeholder="7"
|
||
/>
|
||
</Field>
|
||
<Field label="Cooldown (generations)" hint="Don't replay within N schedules">
|
||
<NumberInput
|
||
value={policy.cooldown_generations ?? ""}
|
||
onChange={(v) => onChange({ ...policy, cooldown_generations: v === "" ? null : (v as number) })}
|
||
min={0}
|
||
placeholder="3"
|
||
/>
|
||
</Field>
|
||
</div>
|
||
<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"]}
|
||
>
|
||
<NumberInput
|
||
value={policy.min_available_ratio}
|
||
onChange={(v) => onChange({ ...policy, min_available_ratio: v === "" ? 0.1 : (v as number) })}
|
||
min={0}
|
||
max={1}
|
||
step={0.01}
|
||
placeholder="0.1"
|
||
error={!!errors["recycle_policy.min_available_ratio"]}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main sheet
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface EditChannelSheetProps {
|
||
channel: ChannelResponse | null;
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
onSubmit: (
|
||
id: string,
|
||
data: {
|
||
name: string;
|
||
description: string;
|
||
timezone: string;
|
||
schedule_config: { blocks: ProgrammingBlock[] };
|
||
recycle_policy: RecyclePolicy;
|
||
auto_schedule: boolean;
|
||
access_mode?: AccessMode;
|
||
access_password?: string;
|
||
logo?: string | null;
|
||
logo_position?: LogoPosition;
|
||
logo_opacity?: number;
|
||
webhook_url?: string | null;
|
||
webhook_poll_interval_secs?: number;
|
||
webhook_body_template?: string | null;
|
||
webhook_headers?: string | null;
|
||
},
|
||
) => void;
|
||
isPending: boolean;
|
||
error?: string | null;
|
||
providers?: ProviderInfo[];
|
||
}
|
||
|
||
export function EditChannelSheet({
|
||
channel,
|
||
open,
|
||
onOpenChange,
|
||
onSubmit,
|
||
isPending,
|
||
error,
|
||
providers = [],
|
||
}: EditChannelSheetProps) {
|
||
const [name, setName] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
const [timezone, setTimezone] = useState("UTC");
|
||
const [blocks, setBlocks] = useState<ProgrammingBlock[]>([]);
|
||
const [recyclePolicy, setRecyclePolicy] = useState<RecyclePolicy>({
|
||
cooldown_days: null,
|
||
cooldown_generations: null,
|
||
min_available_ratio: 0.1,
|
||
});
|
||
const [autoSchedule, setAutoSchedule] = useState(false);
|
||
const [accessMode, setAccessMode] = useState<AccessMode>("public");
|
||
const [accessPassword, setAccessPassword] = useState("");
|
||
const [logo, setLogo] = useState<string | null>(null);
|
||
const [logoPosition, setLogoPosition] = useState<LogoPosition>("top_right");
|
||
const [logoOpacity, setLogoOpacity] = useState(100);
|
||
const [webhookUrl, setWebhookUrl] = useState("");
|
||
const [webhookPollInterval, setWebhookPollInterval] = useState<number | "">(5);
|
||
const [webhookFormat, setWebhookFormat] = useState<WebhookFormat>("default");
|
||
const [webhookBodyTemplate, setWebhookBodyTemplate] = useState("");
|
||
const [webhookHeaders, setWebhookHeaders] = useState("");
|
||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (channel) {
|
||
setName(channel.name);
|
||
setDescription(channel.description ?? "");
|
||
setTimezone(channel.timezone);
|
||
setBlocks(channel.schedule_config.blocks);
|
||
setRecyclePolicy(channel.recycle_policy);
|
||
setAutoSchedule(channel.auto_schedule);
|
||
setAccessMode(channel.access_mode ?? "public");
|
||
setAccessPassword("");
|
||
setLogo(channel.logo ?? null);
|
||
setLogoPosition(channel.logo_position ?? "top_right");
|
||
setLogoOpacity(Math.round((channel.logo_opacity ?? 1) * 100));
|
||
setWebhookUrl(channel.webhook_url ?? "");
|
||
setWebhookPollInterval(channel.webhook_poll_interval_secs ?? 5);
|
||
const tmpl = channel.webhook_body_template ?? "";
|
||
setWebhookBodyTemplate(tmpl);
|
||
setWebhookHeaders(channel.webhook_headers ?? "");
|
||
if (!tmpl) {
|
||
setWebhookFormat("default");
|
||
} else if (tmpl === WEBHOOK_PRESETS.discord) {
|
||
setWebhookFormat("discord");
|
||
} else if (tmpl === WEBHOOK_PRESETS.slack) {
|
||
setWebhookFormat("slack");
|
||
} else {
|
||
setWebhookFormat("custom");
|
||
}
|
||
setSelectedBlockId(null);
|
||
setFieldErrors({});
|
||
}
|
||
}, [channel]);
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!channel) return;
|
||
|
||
const result = channelFormSchema.safeParse({
|
||
name, description, timezone, blocks, recycle_policy: recyclePolicy,
|
||
auto_schedule: autoSchedule, access_mode: accessMode, access_password: accessPassword,
|
||
});
|
||
|
||
if (!result.success) {
|
||
setFieldErrors(extractErrors(result.error));
|
||
return;
|
||
}
|
||
|
||
setFieldErrors({});
|
||
onSubmit(channel.id, {
|
||
name,
|
||
description,
|
||
timezone,
|
||
schedule_config: { blocks },
|
||
recycle_policy: recyclePolicy,
|
||
auto_schedule: autoSchedule,
|
||
access_mode: accessMode !== "public" ? accessMode : "public",
|
||
access_password: accessPassword || "",
|
||
logo: logo,
|
||
logo_position: logoPosition,
|
||
logo_opacity: logoOpacity / 100,
|
||
webhook_url: webhookUrl || null,
|
||
...(webhookUrl
|
||
? { webhook_poll_interval_secs: webhookPollInterval === "" ? 5 : webhookPollInterval }
|
||
: {}),
|
||
webhook_body_template: webhookBodyTemplate || null,
|
||
webhook_headers: webhookHeaders || null,
|
||
});
|
||
};
|
||
|
||
const addBlock = (startMins = 20 * 60, durationMins = 60) => {
|
||
const block = defaultBlock(startMins, durationMins);
|
||
setBlocks((prev) => [...prev, block]);
|
||
setSelectedBlockId(block.id);
|
||
};
|
||
|
||
const updateBlock = (idx: number, block: ProgrammingBlock) =>
|
||
setBlocks((prev) => prev.map((b, i) => (i === idx ? block : b)));
|
||
|
||
const removeBlock = (idx: number) => {
|
||
setBlocks((prev) => {
|
||
const next = prev.filter((_, i) => i !== idx);
|
||
if (selectedBlockId === prev[idx].id) setSelectedBlockId(null);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||
<SheetContent
|
||
side="right"
|
||
className="flex w-full flex-col gap-0 border-zinc-800 bg-zinc-900 p-0 text-zinc-100 sm:!max-w-[min(1100px,95vw)]"
|
||
>
|
||
<SheetHeader className="border-b border-zinc-800 px-6 py-4">
|
||
<SheetTitle className="text-zinc-100">Edit channel</SheetTitle>
|
||
</SheetHeader>
|
||
|
||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">
|
||
<div className="flex flex-1 overflow-hidden">
|
||
{/* Left column — basic info, logo, recycle policy, webhook */}
|
||
<div className="w-[300px] shrink-0 overflow-y-auto border-r border-zinc-800 px-5 py-4 space-y-6">
|
||
{/* Basic info */}
|
||
<section className="space-y-3">
|
||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Basic info</h3>
|
||
|
||
<Field label="Name" error={fieldErrors["name"]}>
|
||
<TextInput
|
||
required
|
||
value={name}
|
||
onChange={setName}
|
||
placeholder="90s Sitcom Network"
|
||
error={!!fieldErrors["name"]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="Timezone" hint="IANA timezone, e.g. America/New_York" error={fieldErrors["timezone"]}>
|
||
<TextInput
|
||
required
|
||
value={timezone}
|
||
onChange={setTimezone}
|
||
placeholder="UTC"
|
||
error={!!fieldErrors["timezone"]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="Description">
|
||
<textarea
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
rows={2}
|
||
placeholder="Nothing but classic sitcoms, all day"
|
||
className="w-full resize-none 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"
|
||
/>
|
||
</Field>
|
||
|
||
<label className="flex items-center justify-between gap-3 cursor-pointer rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2.5">
|
||
<div>
|
||
<p className="text-sm text-zinc-200">Auto-generate schedule</p>
|
||
<p className="text-[11px] text-zinc-600">Automatically regenerate when the schedule is about to expire</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={autoSchedule}
|
||
onClick={() => setAutoSchedule((v) => !v)}
|
||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${autoSchedule ? "bg-zinc-300" : "bg-zinc-700"}`}
|
||
>
|
||
<span
|
||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${autoSchedule ? "translate-x-4" : "translate-x-0"}`}
|
||
/>
|
||
</button>
|
||
</label>
|
||
|
||
<Field label="Channel access">
|
||
<NativeSelect value={accessMode} onChange={(v) => { setAccessMode(v as AccessMode); setAccessPassword(""); }}>
|
||
<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>
|
||
</NativeSelect>
|
||
</Field>
|
||
|
||
{accessMode === "password_protected" && (
|
||
<Field label="Channel password" hint="Leave blank to keep existing password">
|
||
<TextInput
|
||
value={accessPassword}
|
||
onChange={setAccessPassword}
|
||
placeholder="New password…"
|
||
/>
|
||
</Field>
|
||
)}
|
||
</section>
|
||
|
||
{/* Logo */}
|
||
<section className="space-y-3">
|
||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Logo</h3>
|
||
|
||
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800/50 p-3">
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="xs"
|
||
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
Upload image
|
||
</Button>
|
||
{logo && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="xs"
|
||
className="text-zinc-500 hover:text-red-400"
|
||
onClick={() => setLogo(null)}
|
||
>
|
||
Clear
|
||
</Button>
|
||
)}
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*,.svg"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => setLogo(ev.target?.result as string ?? null);
|
||
reader.readAsDataURL(file);
|
||
e.target.value = "";
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<Field label="URL or SVG markup" hint="Paste a remote URL, data URI, or inline SVG">
|
||
<textarea
|
||
rows={3}
|
||
value={logo ?? ""}
|
||
onChange={(e) => setLogo(e.target.value || null)}
|
||
placeholder="https://… or <svg>…</svg>"
|
||
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"
|
||
/>
|
||
</Field>
|
||
|
||
{logo && (
|
||
<div className="flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900 p-3">
|
||
{logo.trimStart().startsWith("<") ? (
|
||
<div dangerouslySetInnerHTML={{ __html: logo }} className="h-12 w-auto" />
|
||
) : (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={logo} alt="" className="h-12 w-auto object-contain" />
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Position">
|
||
<NativeSelect value={logoPosition} onChange={(v) => setLogoPosition(v as LogoPosition)}>
|
||
<option value="top_left">Top left</option>
|
||
<option value="top_right">Top right</option>
|
||
<option value="bottom_left">Bottom left</option>
|
||
<option value="bottom_right">Bottom right</option>
|
||
</NativeSelect>
|
||
</Field>
|
||
<Field label={`Opacity (${logoOpacity}%)`}>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={100}
|
||
value={logoOpacity}
|
||
onChange={(e) => setLogoOpacity(Number(e.target.value))}
|
||
className="w-full accent-zinc-400 mt-2"
|
||
/>
|
||
</Field>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Recycle policy */}
|
||
<section className="space-y-3">
|
||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Recycle policy</h3>
|
||
<RecyclePolicyEditor
|
||
policy={recyclePolicy}
|
||
errors={fieldErrors}
|
||
onChange={setRecyclePolicy}
|
||
/>
|
||
</section>
|
||
|
||
{/* Webhook */}
|
||
<section className="space-y-3">
|
||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Webhook</h3>
|
||
<Field label="Webhook URL" hint="POST events to this URL on broadcast changes">
|
||
<TextInput
|
||
value={webhookUrl}
|
||
onChange={setWebhookUrl}
|
||
placeholder="https://example.com/webhook"
|
||
/>
|
||
</Field>
|
||
{webhookUrl && (
|
||
<>
|
||
<Field label="Poll interval (seconds)" hint="How often to check for broadcast changes">
|
||
<NumberInput
|
||
value={webhookPollInterval}
|
||
onChange={setWebhookPollInterval}
|
||
min={1}
|
||
placeholder="5"
|
||
/>
|
||
</Field>
|
||
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-zinc-400">Payload format</p>
|
||
<div className="flex gap-1.5 flex-wrap">
|
||
{(["default", "discord", "slack", "custom"] as WebhookFormat[]).map((fmt) => (
|
||
<button
|
||
key={fmt}
|
||
type="button"
|
||
onClick={() => {
|
||
setWebhookFormat(fmt);
|
||
if (fmt === "discord") setWebhookBodyTemplate(WEBHOOK_PRESETS.discord);
|
||
else if (fmt === "slack") setWebhookBodyTemplate(WEBHOOK_PRESETS.slack);
|
||
else if (fmt === "default") setWebhookBodyTemplate("");
|
||
}}
|
||
className={`rounded px-2.5 py-1 text-xs font-medium capitalize transition-colors ${
|
||
webhookFormat === fmt
|
||
? "bg-zinc-600 text-zinc-100"
|
||
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||
}`}
|
||
>
|
||
{fmt === "default" ? "K-TV default" : fmt}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{webhookFormat !== "default" && (
|
||
<Field
|
||
label="Body template (Handlebars)"
|
||
hint="Context: event, timestamp, channel_id, data.item.title, data.item.duration_secs, …"
|
||
>
|
||
<textarea
|
||
rows={6}
|
||
value={webhookBodyTemplate}
|
||
onChange={(e) => {
|
||
setWebhookBodyTemplate(e.target.value);
|
||
setWebhookFormat("custom");
|
||
}}
|
||
placeholder={'{\n "text": "Now playing: {{data.item.title}}"\n}'}
|
||
className="w-full resize-y 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"
|
||
/>
|
||
</Field>
|
||
)}
|
||
|
||
<Field
|
||
label="Extra headers (JSON)"
|
||
hint={'e.g. {"Authorization": "Bearer token"}'}
|
||
>
|
||
<textarea
|
||
rows={2}
|
||
value={webhookHeaders}
|
||
onChange={(e) => setWebhookHeaders(e.target.value)}
|
||
placeholder={'{"Authorization": "Bearer xxx"}'}
|
||
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"
|
||
/>
|
||
</Field>
|
||
</>
|
||
)}
|
||
</section>
|
||
</div>
|
||
|
||
{/* Right column — programming blocks */}
|
||
<div className="flex flex-1 flex-col overflow-hidden">
|
||
{/* Upper half: timeline + block list */}
|
||
<div className="shrink-0 border-b border-zinc-800 px-5 py-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||
Programming blocks
|
||
</h3>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="xs"
|
||
onClick={() => addBlock()}
|
||
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
|
||
>
|
||
<Plus className="size-3" />
|
||
Add block
|
||
</Button>
|
||
</div>
|
||
|
||
<BlockTimeline
|
||
blocks={blocks}
|
||
selectedId={selectedBlockId}
|
||
onSelect={setSelectedBlockId}
|
||
onChange={setBlocks}
|
||
onCreateBlock={(startMins, durationMins) => addBlock(startMins, durationMins)}
|
||
/>
|
||
|
||
{blocks.length === 0 ? (
|
||
<p className="rounded-md border border-dashed border-zinc-700 px-4 py-4 text-center text-xs text-zinc-600">
|
||
No blocks yet. Drag on the timeline or click Add block.
|
||
</p>
|
||
) : (
|
||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||
{blocks.map((block, idx) => (
|
||
<button
|
||
key={block.id}
|
||
type="button"
|
||
onClick={() => setSelectedBlockId(block.id)}
|
||
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left transition-colors ${
|
||
block.id === selectedBlockId
|
||
? "border border-zinc-600 bg-zinc-700/60"
|
||
: "border border-transparent hover:bg-zinc-800/60"
|
||
}`}
|
||
>
|
||
<div
|
||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||
style={{ backgroundColor: BLOCK_COLORS[idx % BLOCK_COLORS.length] }}
|
||
/>
|
||
<span className="flex-1 truncate text-sm text-zinc-200">
|
||
{block.name || "Unnamed block"}
|
||
</span>
|
||
<span className="shrink-0 font-mono text-[11px] text-zinc-500">
|
||
{block.start_time.slice(0, 5)} · {block.duration_mins}m
|
||
</span>
|
||
<span
|
||
role="button"
|
||
onClick={(e) => { e.stopPropagation(); removeBlock(idx); }}
|
||
className="rounded p-1 text-zinc-600 hover:bg-zinc-700 hover:text-red-400"
|
||
>
|
||
<Trash2 className="size-3.5" />
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Lower half: selected block detail */}
|
||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||
{(() => {
|
||
if (selectedBlockId === null) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center text-sm text-zinc-600">
|
||
Select a block or create one
|
||
</div>
|
||
);
|
||
}
|
||
const selectedIdx = blocks.findIndex((b) => b.id === selectedBlockId);
|
||
const selectedBlock = selectedIdx >= 0 ? blocks[selectedIdx] : null;
|
||
if (!selectedBlock) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center text-sm text-zinc-600">
|
||
Select a block or create one
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<BlockEditor
|
||
block={selectedBlock}
|
||
index={selectedIdx}
|
||
errors={fieldErrors}
|
||
onChange={(b) => updateBlock(selectedIdx, b)}
|
||
providers={providers}
|
||
/>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-between border-t border-zinc-800 px-6 py-4">
|
||
{(error || Object.keys(fieldErrors).length > 0) && (
|
||
<p className="text-xs text-red-400">
|
||
{error ?? "Please fix the errors above"}
|
||
</p>
|
||
)}
|
||
<div className="ml-auto flex gap-2">
|
||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||
Cancel
|
||
</Button>
|
||
<Button type="submit" disabled={isPending}>
|
||
{isPending ? "Saving…" : "Save changes"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|