* fix(admin): use collection urlPattern for preview button fallback URL
The preview button hardcoded fallback URLs as /${collection}/${slug},
ignoring the collection's urlPattern setting. Collections with custom
URL patterns (e.g. urlPattern: "/biljke/{slug}" on a "biljka" collection)
would open a 404 instead of the correct page.
Thread urlPattern through the manifest and use it in the ContentEditor
preview fallback.
Fixes #167
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Filip Ilic <ilic.filip@gmail.com>
* chore: add changeset for preview URL pattern fix
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Filip Ilic <ilic.filip@gmail.com>
---------
Signed-off-by: Filip Ilic <ilic.filip@gmail.com>
Co-authored-by: Matt Kane <mkane@cloudflare.com>
1578 lines
46 KiB
TypeScript
1578 lines
46 KiB
TypeScript
import {
|
||
Badge,
|
||
Button,
|
||
Dialog,
|
||
Input,
|
||
InputArea,
|
||
Label,
|
||
Loader,
|
||
Select,
|
||
Switch,
|
||
buttonVariants,
|
||
} from "@cloudflare/kumo";
|
||
import {
|
||
ArrowLeft,
|
||
Check,
|
||
Eye,
|
||
Image as ImageIcon,
|
||
MagnifyingGlass,
|
||
X,
|
||
Trash,
|
||
ArrowsInSimple,
|
||
ArrowsOutSimple,
|
||
} from "@phosphor-icons/react";
|
||
import { Link } from "@tanstack/react-router";
|
||
import type { Editor } from "@tiptap/react";
|
||
import * as React from "react";
|
||
|
||
import type {
|
||
BylineCreditInput,
|
||
BylineSummary,
|
||
ContentItem,
|
||
MediaItem,
|
||
UserListItem,
|
||
TranslationSummary,
|
||
} from "../lib/api";
|
||
import { getPreviewUrl, getDraftStatus } from "../lib/api";
|
||
import { usePluginAdmins } from "../lib/plugin-context.js";
|
||
import { cn, slugify } from "../lib/utils";
|
||
import { BlockKitFieldWidget } from "./BlockKitFieldWidget.js";
|
||
import { DocumentOutline } from "./editor/DocumentOutline";
|
||
import { PluginFieldErrorBoundary } from "./PluginFieldErrorBoundary.js";
|
||
|
||
/** Autosave debounce delay in milliseconds */
|
||
const AUTOSAVE_DELAY = 2000;
|
||
|
||
function serializeEditorState(input: {
|
||
data: Record<string, unknown>;
|
||
slug: string;
|
||
bylines: BylineCreditInput[];
|
||
}) {
|
||
return JSON.stringify({
|
||
data: input.data,
|
||
slug: input.slug,
|
||
bylines: input.bylines,
|
||
});
|
||
}
|
||
|
||
import type { ContentSeoInput } from "../lib/api";
|
||
import { ImageDetailPanel } from "./editor/ImageDetailPanel";
|
||
import type { ImageAttributes } from "./editor/ImageDetailPanel";
|
||
import { MediaPickerModal } from "./MediaPickerModal";
|
||
import {
|
||
PortableTextEditor,
|
||
type PluginBlockDef,
|
||
type BlockSidebarPanel,
|
||
} from "./PortableTextEditor";
|
||
import { RevisionHistory } from "./RevisionHistory";
|
||
import { SaveButton } from "./SaveButton";
|
||
import { SeoPanel } from "./SeoPanel";
|
||
import { TaxonomySidebar } from "./TaxonomySidebar";
|
||
|
||
// Editor role level (40) from @emdash-cms/auth
|
||
const ROLE_EDITOR = 40;
|
||
|
||
export interface FieldDescriptor {
|
||
kind: string;
|
||
label?: string;
|
||
required?: boolean;
|
||
options?: Array<{ value: string; label: string }>;
|
||
widget?: string;
|
||
}
|
||
|
||
/** Simplified user info for current user context */
|
||
export interface CurrentUserInfo {
|
||
id: string;
|
||
role: number;
|
||
}
|
||
|
||
export interface ContentEditorProps {
|
||
collection: string;
|
||
collectionLabel: string;
|
||
item?: ContentItem | null;
|
||
fields: Record<string, FieldDescriptor>;
|
||
isNew?: boolean;
|
||
isSaving?: boolean;
|
||
onSave?: (payload: {
|
||
data: Record<string, unknown>;
|
||
slug?: string;
|
||
bylines?: BylineCreditInput[];
|
||
}) => void;
|
||
/** Callback for autosave (debounced, skips revision creation) */
|
||
onAutosave?: (payload: {
|
||
data: Record<string, unknown>;
|
||
slug?: string;
|
||
bylines?: BylineCreditInput[];
|
||
}) => void;
|
||
/** Whether autosave is in progress */
|
||
isAutosaving?: boolean;
|
||
/** Last autosave timestamp (for UI indicator) */
|
||
lastAutosaveAt?: Date | null;
|
||
onPublish?: () => void;
|
||
onUnpublish?: () => void;
|
||
/** Callback to discard draft changes (revert to published version) */
|
||
onDiscardDraft?: () => void;
|
||
/** Callback to schedule for future publishing */
|
||
onSchedule?: (scheduledAt: string) => void;
|
||
/** Callback to cancel scheduling (revert to draft) */
|
||
onUnschedule?: () => void;
|
||
/** Whether scheduling is in progress */
|
||
isScheduling?: boolean;
|
||
/** Whether this collection supports drafts */
|
||
supportsDrafts?: boolean;
|
||
/** Whether this collection supports revisions */
|
||
supportsRevisions?: boolean;
|
||
/** Current user (for permission checks) */
|
||
currentUser?: CurrentUserInfo;
|
||
/** Available users for author selection (only shown to editors+) */
|
||
users?: UserListItem[];
|
||
/** Callback when author is changed */
|
||
onAuthorChange?: (authorId: string | null) => void;
|
||
/** Available byline profiles */
|
||
availableBylines?: BylineSummary[];
|
||
/** Selected byline credits (controlled for new entries) */
|
||
selectedBylines?: BylineCreditInput[];
|
||
/** Callback when byline credits are changed */
|
||
onBylinesChange?: (bylines: BylineCreditInput[]) => void;
|
||
/** Callback for creating a byline inline from the editor */
|
||
onQuickCreateByline?: (input: { slug: string; displayName: string }) => Promise<BylineSummary>;
|
||
/** Callback for updating a byline inline from the editor */
|
||
onQuickEditByline?: (
|
||
bylineId: string,
|
||
input: { slug: string; displayName: string },
|
||
) => Promise<BylineSummary>;
|
||
/** Callback when item is deleted (moved to trash) */
|
||
onDelete?: () => void;
|
||
/** Whether delete is in progress */
|
||
isDeleting?: boolean;
|
||
/** i18n config — present when multiple locales are configured */
|
||
i18n?: { defaultLocale: string; locales: string[] };
|
||
/** Existing translations for this content item */
|
||
translations?: TranslationSummary[];
|
||
/** Callback to create a translation for a locale */
|
||
onTranslate?: (locale: string) => void;
|
||
/** Plugin block types available for insertion in Portable Text fields */
|
||
pluginBlocks?: PluginBlockDef[];
|
||
/** Whether this collection has SEO fields enabled */
|
||
hasSeo?: boolean;
|
||
/** Callback when SEO fields change */
|
||
onSeoChange?: (seo: ContentSeoInput) => void;
|
||
/** Admin manifest for resolving plugin field widgets */
|
||
manifest?: import("../lib/api/client.js").AdminManifest | null;
|
||
}
|
||
|
||
/** Format scheduled date for display */
|
||
function formatScheduledDate(dateStr: string | null) {
|
||
if (!dateStr) return null;
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleString();
|
||
}
|
||
|
||
/**
|
||
* Content editor with dynamic field rendering
|
||
*/
|
||
export function ContentEditor({
|
||
collection,
|
||
collectionLabel,
|
||
item,
|
||
fields,
|
||
isNew,
|
||
isSaving,
|
||
onSave,
|
||
onAutosave,
|
||
isAutosaving,
|
||
lastAutosaveAt,
|
||
onPublish,
|
||
onUnpublish,
|
||
onDiscardDraft,
|
||
onSchedule,
|
||
onUnschedule,
|
||
isScheduling,
|
||
supportsDrafts = false,
|
||
supportsRevisions = false,
|
||
currentUser,
|
||
users,
|
||
onAuthorChange,
|
||
availableBylines,
|
||
selectedBylines,
|
||
onBylinesChange,
|
||
onQuickCreateByline,
|
||
onQuickEditByline,
|
||
onDelete,
|
||
isDeleting,
|
||
i18n,
|
||
translations,
|
||
onTranslate,
|
||
pluginBlocks,
|
||
hasSeo = false,
|
||
onSeoChange,
|
||
manifest,
|
||
}: ContentEditorProps) {
|
||
const [formData, setFormData] = React.useState<Record<string, unknown>>(item?.data || {});
|
||
const [slug, setSlug] = React.useState(item?.slug || "");
|
||
const [slugTouched, setSlugTouched] = React.useState(!!item?.slug);
|
||
const [status, setStatus] = React.useState(item?.status || "draft");
|
||
const [internalBylines, setInternalBylines] = React.useState<BylineCreditInput[]>(
|
||
item?.bylines?.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel })) ??
|
||
[],
|
||
);
|
||
|
||
// Track portableText editor for document outline
|
||
const [portableTextEditor, setPortableTextEditor] = React.useState<Editor | null>(null);
|
||
|
||
// Block sidebar state – when a block (e.g. image) requests sidebar space, this holds
|
||
// the panel data. When non-null the sidebar shows the block panel instead of the
|
||
// default content settings sections.
|
||
const [blockSidebarPanel, setBlockSidebarPanel] = React.useState<BlockSidebarPanel | null>(null);
|
||
|
||
const handleBlockSidebarOpen = React.useCallback((panel: BlockSidebarPanel) => {
|
||
setBlockSidebarPanel(panel);
|
||
}, []);
|
||
|
||
const handleBlockSidebarClose = React.useCallback(() => {
|
||
setBlockSidebarPanel((prev) => {
|
||
prev?.onClose();
|
||
return null;
|
||
});
|
||
}, []);
|
||
|
||
// Track the last saved state to determine if dirty
|
||
const [lastSavedData, setLastSavedData] = React.useState<string>(
|
||
serializeEditorState({
|
||
data: item?.data || {},
|
||
slug: item?.slug || "",
|
||
bylines:
|
||
item?.bylines?.map((entry) => ({
|
||
bylineId: entry.byline.id,
|
||
roleLabel: entry.roleLabel,
|
||
})) ?? [],
|
||
}),
|
||
);
|
||
|
||
// Update form and last saved state when item changes (e.g., after save or restore)
|
||
// Stringify the data for comparison since objects are compared by reference
|
||
const itemDataString = React.useMemo(() => (item ? JSON.stringify(item.data) : ""), [item?.data]);
|
||
React.useEffect(() => {
|
||
if (item) {
|
||
setFormData(item.data);
|
||
setSlug(item.slug || "");
|
||
setSlugTouched(!!item.slug);
|
||
setStatus(item.status);
|
||
setInternalBylines(
|
||
item.bylines?.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel })) ??
|
||
[],
|
||
);
|
||
setLastSavedData(
|
||
serializeEditorState({
|
||
data: item.data,
|
||
slug: item.slug || "",
|
||
bylines:
|
||
item.bylines?.map((entry) => ({
|
||
bylineId: entry.byline.id,
|
||
roleLabel: entry.roleLabel,
|
||
})) ?? [],
|
||
}),
|
||
);
|
||
}
|
||
}, [item?.updatedAt, itemDataString, item?.slug, item?.status]);
|
||
|
||
const activeBylines = isNew ? (selectedBylines ?? []) : internalBylines;
|
||
|
||
const handleBylinesChange = React.useCallback(
|
||
(next: BylineCreditInput[]) => {
|
||
if (isNew) {
|
||
onBylinesChange?.(next);
|
||
return;
|
||
}
|
||
setInternalBylines(next);
|
||
onBylinesChange?.(next);
|
||
},
|
||
[isNew, onBylinesChange],
|
||
);
|
||
|
||
// Check if form has unsaved changes
|
||
const currentData = React.useMemo(
|
||
() =>
|
||
serializeEditorState({
|
||
data: formData,
|
||
slug,
|
||
bylines: activeBylines,
|
||
}),
|
||
[formData, slug, activeBylines],
|
||
);
|
||
const isDirty = isNew || currentData !== lastSavedData;
|
||
|
||
// Autosave with debounce
|
||
// Track pending autosave to cancel on manual save
|
||
const autosaveTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const formDataRef = React.useRef(formData);
|
||
formDataRef.current = formData;
|
||
const slugRef = React.useRef(slug);
|
||
slugRef.current = slug;
|
||
|
||
React.useEffect(() => {
|
||
// Don't autosave for new items (no ID yet) or if autosave isn't configured
|
||
if (isNew || !onAutosave || !item?.id) {
|
||
return;
|
||
}
|
||
|
||
// Don't autosave if not dirty or already saving
|
||
if (!isDirty || isSaving || isAutosaving) {
|
||
return;
|
||
}
|
||
|
||
// Clear any pending autosave
|
||
if (autosaveTimeoutRef.current) {
|
||
clearTimeout(autosaveTimeoutRef.current);
|
||
}
|
||
|
||
// Schedule autosave
|
||
autosaveTimeoutRef.current = setTimeout(() => {
|
||
onAutosave({
|
||
data: formDataRef.current,
|
||
slug: slugRef.current || undefined,
|
||
bylines: activeBylines,
|
||
});
|
||
}, AUTOSAVE_DELAY);
|
||
|
||
return () => {
|
||
if (autosaveTimeoutRef.current) {
|
||
clearTimeout(autosaveTimeoutRef.current);
|
||
}
|
||
};
|
||
}, [currentData, isNew, onAutosave, item?.id, isDirty, isSaving, isAutosaving, activeBylines]);
|
||
|
||
// Cancel pending autosave on manual save
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
// Cancel pending autosave
|
||
if (autosaveTimeoutRef.current) {
|
||
clearTimeout(autosaveTimeoutRef.current);
|
||
autosaveTimeoutRef.current = null;
|
||
}
|
||
onSave?.({
|
||
data: formData,
|
||
slug: slug || undefined,
|
||
bylines: activeBylines,
|
||
});
|
||
};
|
||
|
||
// Preview URL state
|
||
const [isLoadingPreview, setIsLoadingPreview] = React.useState(false);
|
||
|
||
const handlePreview = async () => {
|
||
if (!item?.id) return;
|
||
|
||
const contentUrl = (s: string) => {
|
||
const pattern = manifest?.collections[collection]?.urlPattern;
|
||
return pattern ? pattern.replace("{slug}", s) : `/${collection}/${s}`;
|
||
};
|
||
|
||
setIsLoadingPreview(true);
|
||
try {
|
||
const result = await getPreviewUrl(collection, item.id);
|
||
if (result?.url) {
|
||
// Open preview in new tab
|
||
window.open(result.url, "_blank", "noopener,noreferrer");
|
||
} else {
|
||
// Fallback to direct URL if preview not configured
|
||
window.open(contentUrl(slug || item.id), "_blank", "noopener,noreferrer");
|
||
}
|
||
} catch {
|
||
// Fallback to direct URL on error
|
||
window.open(contentUrl(slug || item?.id || ""), "_blank", "noopener,noreferrer");
|
||
} finally {
|
||
setIsLoadingPreview(false);
|
||
}
|
||
};
|
||
|
||
const handleFieldChange = React.useCallback(
|
||
(name: string, value: unknown) => {
|
||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||
if (name === "title" && !slugTouched && typeof value === "string" && value) {
|
||
setSlug(slugify(value));
|
||
}
|
||
},
|
||
[slugTouched],
|
||
);
|
||
|
||
const handleSlugChange = (value: string) => {
|
||
setSlug(value);
|
||
setSlugTouched(true);
|
||
};
|
||
|
||
const isPublished = status === "published";
|
||
|
||
// Draft revision status (only meaningful when supportsDrafts is on)
|
||
const draftStatus = item ? getDraftStatus(item) : "unpublished";
|
||
const hasPendingChanges = draftStatus === "published_with_changes";
|
||
const isLive = draftStatus === "published" || draftStatus === "published_with_changes";
|
||
|
||
// Scheduling — keyed off scheduledAt rather than status, since published
|
||
// posts can now have a pending schedule without changing status.
|
||
const hasSchedule = Boolean(item?.scheduledAt);
|
||
const canSchedule =
|
||
!isNew && !hasSchedule && Boolean(onSchedule) && (!isPublished || hasPendingChanges);
|
||
|
||
// Schedule datetime state
|
||
const [scheduleDate, setScheduleDate] = React.useState<string>("");
|
||
const [showScheduler, setShowScheduler] = React.useState(false);
|
||
|
||
// Distraction-free mode state
|
||
const [isDistractionFree, setIsDistractionFree] = React.useState(false);
|
||
|
||
// Escape exits distraction-free mode
|
||
React.useEffect(() => {
|
||
if (!isDistractionFree) return;
|
||
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setIsDistractionFree(false);
|
||
}
|
||
};
|
||
|
||
document.addEventListener("keydown", handleKeyDown, { capture: true });
|
||
return () => document.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||
}, [isDistractionFree]);
|
||
|
||
const handleScheduleSubmit = () => {
|
||
if (scheduleDate && onSchedule) {
|
||
// Convert local datetime to ISO string
|
||
const date = new Date(scheduleDate);
|
||
onSchedule(date.toISOString());
|
||
setShowScheduler(false);
|
||
setScheduleDate("");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<form
|
||
onSubmit={handleSubmit}
|
||
className={cn(
|
||
"space-y-6 transition-all duration-300",
|
||
isDistractionFree && "fixed inset-0 z-50 bg-kumo-base p-8 overflow-auto",
|
||
)}
|
||
>
|
||
{/* Header - show on hover in distraction-free mode */}
|
||
<div
|
||
className={cn(
|
||
"flex flex-wrap items-center justify-between gap-y-2",
|
||
isDistractionFree &&
|
||
"opacity-0 hover:opacity-100 transition-opacity duration-200 fixed top-0 left-0 right-0 bg-kumo-base/95 backdrop-blur p-4 z-10",
|
||
)}
|
||
>
|
||
<div className="flex items-center space-x-4">
|
||
{!isDistractionFree && (
|
||
<Link
|
||
to="/content/$collection"
|
||
params={{ collection }}
|
||
search={{ locale: undefined }}
|
||
aria-label={`Back to ${collectionLabel} list`}
|
||
className={buttonVariants({ variant: "ghost", shape: "square" })}
|
||
>
|
||
<ArrowLeft className="h-5 w-5" aria-hidden="true" />
|
||
</Link>
|
||
)}
|
||
{isDistractionFree && (
|
||
<Button
|
||
variant="ghost"
|
||
shape="square"
|
||
onClick={() => setIsDistractionFree(false)}
|
||
aria-label="Exit distraction-free mode"
|
||
>
|
||
<ArrowsInSimple className="h-5 w-5" aria-hidden="true" />
|
||
</Button>
|
||
)}
|
||
<h1 className="text-2xl font-bold">
|
||
{isNew ? `New ${collectionLabel}` : `Edit ${collectionLabel}`}
|
||
</h1>
|
||
{i18n && item?.locale && (
|
||
<Badge variant="outline" className="uppercase text-xs">
|
||
{item.locale}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
{/* Autosave indicator */}
|
||
{!isNew && onAutosave && (
|
||
<div className="flex items-center text-xs text-kumo-subtle">
|
||
{isAutosaving ? (
|
||
<>
|
||
<Loader size="sm" />
|
||
<span className="ml-1">Saving...</span>
|
||
</>
|
||
) : lastAutosaveAt ? (
|
||
<>
|
||
<Check className="mr-1 h-3 w-3 text-green-600" aria-hidden="true" />
|
||
<span>Saved</span>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
{!isDistractionFree && (
|
||
<Button
|
||
variant="ghost"
|
||
shape="square"
|
||
type="button"
|
||
onClick={() => setIsDistractionFree(true)}
|
||
aria-label="Enter distraction-free mode"
|
||
title="Distraction-free mode (⌘⇧\)"
|
||
>
|
||
<ArrowsOutSimple className="h-4 w-4" aria-hidden="true" />
|
||
</Button>
|
||
)}
|
||
{!isNew && (
|
||
<Button
|
||
variant="outline"
|
||
type="button"
|
||
onClick={handlePreview}
|
||
disabled={isLoadingPreview}
|
||
icon={isLoadingPreview ? <Loader size="sm" /> : <Eye />}
|
||
>
|
||
{hasPendingChanges ? "Preview draft" : "Preview"}
|
||
</Button>
|
||
)}
|
||
<SaveButton type="submit" isDirty={isDirty} isSaving={isSaving || false} />
|
||
{!isNew && (
|
||
<>
|
||
{supportsDrafts && hasPendingChanges && onDiscardDraft && (
|
||
<Dialog.Root disablePointerDismissal>
|
||
<Dialog.Trigger
|
||
render={(p) => (
|
||
<Button {...p} type="button" variant="outline" size="sm" icon={<X />}>
|
||
Discard changes
|
||
</Button>
|
||
)}
|
||
/>
|
||
<Dialog className="p-6" size="sm">
|
||
<Dialog.Title className="text-lg font-semibold">
|
||
Discard draft changes?
|
||
</Dialog.Title>
|
||
<Dialog.Description className="text-kumo-subtle">
|
||
This will revert to the published version. Your draft changes will be lost.
|
||
</Dialog.Description>
|
||
<div className="mt-6 flex justify-end gap-2">
|
||
<Dialog.Close
|
||
render={(p) => (
|
||
<Button {...p} variant="secondary">
|
||
Cancel
|
||
</Button>
|
||
)}
|
||
/>
|
||
<Dialog.Close
|
||
render={(p) => (
|
||
<Button {...p} variant="destructive" onClick={onDiscardDraft}>
|
||
Discard changes
|
||
</Button>
|
||
)}
|
||
/>
|
||
</div>
|
||
</Dialog>
|
||
</Dialog.Root>
|
||
)}
|
||
{isLive ? (
|
||
<>
|
||
{hasPendingChanges ? (
|
||
<Button type="button" variant="primary" onClick={onPublish}>
|
||
Publish changes
|
||
</Button>
|
||
) : (
|
||
<Button type="button" variant="outline" onClick={onUnpublish}>
|
||
Unpublish
|
||
</Button>
|
||
)}
|
||
</>
|
||
) : (
|
||
<Button type="button" variant="secondary" onClick={onPublish}>
|
||
Publish
|
||
</Button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main content area */}
|
||
<div
|
||
className={cn(
|
||
"grid gap-6 lg:grid-cols-3",
|
||
isDistractionFree && "lg:grid-cols-1 max-w-4xl mx-auto pt-16",
|
||
)}
|
||
>
|
||
{/* Editor fields */}
|
||
<div className="space-y-6 lg:col-span-2">
|
||
<div
|
||
className={cn(
|
||
"rounded-lg border bg-kumo-base p-6",
|
||
isDistractionFree && "border-0 bg-transparent p-0",
|
||
)}
|
||
>
|
||
<div className="space-y-4">
|
||
{Object.entries(fields).map(([name, field]) => (
|
||
<FieldRenderer
|
||
key={name}
|
||
name={name}
|
||
field={field}
|
||
value={formData[name]}
|
||
onChange={handleFieldChange}
|
||
onEditorReady={field.kind === "portableText" ? setPortableTextEditor : undefined}
|
||
minimal={isDistractionFree}
|
||
pluginBlocks={pluginBlocks}
|
||
onBlockSidebarOpen={
|
||
field.kind === "portableText" ? handleBlockSidebarOpen : undefined
|
||
}
|
||
onBlockSidebarClose={
|
||
field.kind === "portableText" ? handleBlockSidebarClose : undefined
|
||
}
|
||
manifest={manifest}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sidebar - hidden in distraction-free mode */}
|
||
<div className={cn("space-y-6", isDistractionFree && "hidden")}>
|
||
{blockSidebarPanel ? (
|
||
/* Block sidebar panel – replaces default sections when a block requests it */
|
||
blockSidebarPanel.type === "image" ? (
|
||
<ImageDetailPanel
|
||
attributes={blockSidebarPanel.attrs as unknown as ImageAttributes}
|
||
onUpdate={(attrs) =>
|
||
blockSidebarPanel.onUpdate(attrs as unknown as Record<string, unknown>)
|
||
}
|
||
onReplace={(attrs) =>
|
||
blockSidebarPanel.onReplace(attrs as unknown as Record<string, unknown>)
|
||
}
|
||
onDelete={() => {
|
||
blockSidebarPanel.onDelete();
|
||
setBlockSidebarPanel(null);
|
||
}}
|
||
onClose={handleBlockSidebarClose}
|
||
inline
|
||
/>
|
||
) : null
|
||
) : (
|
||
/* Default content settings sections – single card with dividers */
|
||
<div className="rounded-lg border bg-kumo-base flex flex-col">
|
||
{/* Publish settings */}
|
||
<div className="p-4">
|
||
<h3 className="mb-4 font-semibold">Publish</h3>
|
||
<div className="space-y-4">
|
||
<Input
|
||
label="Slug"
|
||
value={slug}
|
||
onChange={(e) => handleSlugChange(e.target.value)}
|
||
placeholder="my-post-slug"
|
||
/>
|
||
<div>
|
||
<Label>Status</Label>
|
||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||
{supportsDrafts ? (
|
||
<>
|
||
{isLive && <Badge variant="primary">Published</Badge>}
|
||
{hasPendingChanges && <Badge variant="secondary">Pending changes</Badge>}
|
||
{!isLive && !hasSchedule && <Badge variant="secondary">Draft</Badge>}
|
||
{hasSchedule && <Badge variant="outline">Scheduled</Badge>}
|
||
</>
|
||
) : (
|
||
<span className="text-sm text-kumo-subtle">
|
||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{item?.scheduledAt && (
|
||
<div className="mt-2 flex items-center justify-between gap-2 rounded-md border px-3 py-2">
|
||
<p className="text-xs text-kumo-subtle">
|
||
Scheduled for: {formatScheduledDate(item.scheduledAt)}
|
||
</p>
|
||
<Button type="button" variant="outline" size="sm" onClick={onUnschedule}>
|
||
Unschedule
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{canSchedule && (
|
||
<div className="pt-2">
|
||
{showScheduler ? (
|
||
<div className="space-y-2">
|
||
<Input
|
||
label="Schedule for"
|
||
type="datetime-local"
|
||
value={scheduleDate}
|
||
onChange={(e) => setScheduleDate(e.target.value)}
|
||
min={new Date().toISOString().slice(0, 16)}
|
||
/>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
onClick={handleScheduleSubmit}
|
||
disabled={!scheduleDate || isScheduling}
|
||
icon={isScheduling ? <Loader size="sm" /> : undefined}
|
||
>
|
||
Schedule
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setShowScheduler(false);
|
||
setScheduleDate("");
|
||
}}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-full"
|
||
onClick={() => setShowScheduler(true)}
|
||
>
|
||
Schedule for later
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{item && (
|
||
<div className="text-xs text-kumo-subtle">
|
||
<p>Created: {new Date(item.createdAt).toLocaleString()}</p>
|
||
<p>Updated: {new Date(item.updatedAt).toLocaleString()}</p>
|
||
</div>
|
||
)}
|
||
{!isNew && onDelete && (
|
||
<div className="pt-4 border-t">
|
||
<Dialog.Root disablePointerDismissal>
|
||
<Dialog.Trigger
|
||
render={(p) => (
|
||
<Button
|
||
{...p}
|
||
type="button"
|
||
variant="outline"
|
||
className="w-full text-kumo-danger hover:text-kumo-danger"
|
||
disabled={isDeleting}
|
||
icon={isDeleting ? <Loader size="sm" /> : <Trash />}
|
||
>
|
||
Move to Trash
|
||
</Button>
|
||
)}
|
||
/>
|
||
<Dialog className="p-6" size="sm">
|
||
<Dialog.Title className="text-lg font-semibold">
|
||
Move to Trash?
|
||
</Dialog.Title>
|
||
<Dialog.Description className="text-kumo-subtle">
|
||
This will move the item to trash. You can restore it later from the
|
||
trash.
|
||
</Dialog.Description>
|
||
<div className="mt-6 flex justify-end gap-2">
|
||
<Dialog.Close
|
||
render={(p) => (
|
||
<Button {...p} variant="secondary">
|
||
Cancel
|
||
</Button>
|
||
)}
|
||
/>
|
||
<Dialog.Close
|
||
render={(p) => (
|
||
<Button {...p} variant="destructive" onClick={onDelete}>
|
||
Move to Trash
|
||
</Button>
|
||
)}
|
||
/>
|
||
</div>
|
||
</Dialog>
|
||
</Dialog.Root>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Ownership selector - shown only to editors and above */}
|
||
{currentUser && currentUser.role >= ROLE_EDITOR && users && users.length > 0 && (
|
||
<div className="p-4 border-t">
|
||
<h3 className="mb-4 font-semibold">Ownership</h3>
|
||
<AuthorSelector
|
||
authorId={item?.authorId || null}
|
||
users={users}
|
||
onChange={onAuthorChange}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Byline credits */}
|
||
{currentUser && currentUser.role >= ROLE_EDITOR && (
|
||
<div className="p-4 border-t">
|
||
<h3 className="mb-4 font-semibold">Bylines</h3>
|
||
<BylineCreditsEditor
|
||
credits={activeBylines}
|
||
bylines={availableBylines ?? []}
|
||
onChange={handleBylinesChange}
|
||
onQuickCreate={onQuickCreateByline}
|
||
onQuickEdit={onQuickEditByline}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Translations sidebar - shown when i18n is enabled */}
|
||
{i18n && item && !isNew && (
|
||
<div className="p-4 border-t">
|
||
<h3 className="mb-4 font-semibold">Translations</h3>
|
||
<div className="space-y-2">
|
||
{i18n.locales.map((locale) => {
|
||
const translation = translations?.find((t) => t.locale === locale);
|
||
const isCurrent = locale === item.locale;
|
||
return (
|
||
<div
|
||
key={locale}
|
||
className={cn(
|
||
"flex items-center justify-between rounded-md px-3 py-2 text-sm",
|
||
isCurrent
|
||
? "bg-kumo-brand/10 font-medium"
|
||
: translation
|
||
? "hover:bg-kumo-tint/50"
|
||
: "text-kumo-subtle",
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-semibold uppercase">{locale}</span>
|
||
{locale === i18n.defaultLocale && (
|
||
<span className="text-[10px] text-kumo-subtle">(default)</span>
|
||
)}
|
||
{isCurrent && (
|
||
<span className="text-[10px] text-kumo-brand">current</span>
|
||
)}
|
||
</div>
|
||
{translation && !isCurrent ? (
|
||
<Link
|
||
to="/content/$collection/$id"
|
||
params={{ collection, id: translation.id }}
|
||
className="text-xs text-kumo-brand hover:underline"
|
||
>
|
||
Edit
|
||
</Link>
|
||
) : !translation && onTranslate ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-auto px-2 py-1 text-xs"
|
||
onClick={() => onTranslate(locale)}
|
||
>
|
||
Translate
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Taxonomy selector */}
|
||
{item && (
|
||
<div className="p-4 border-t">
|
||
<TaxonomySidebar collection={collection} entryId={item.id} />
|
||
</div>
|
||
)}
|
||
|
||
{/* SEO panel - shown for collections with hasSeo enabled */}
|
||
{hasSeo && !isNew && onSeoChange && (
|
||
<div className="p-4 border-t">
|
||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||
<MagnifyingGlass className="h-4 w-4" />
|
||
SEO
|
||
</h3>
|
||
<SeoPanel seo={item?.seo} onChange={onSeoChange} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Document outline - shown when editing content with portableText */}
|
||
{portableTextEditor && (
|
||
<div className="p-4 border-t">
|
||
<DocumentOutline editor={portableTextEditor} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Revision history - shown for existing items in collections that support it */}
|
||
{!isNew && item && supportsRevisions && (
|
||
<div className="p-4 border-t">
|
||
<RevisionHistory collection={collection} entryId={item.id} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
interface FieldRendererProps {
|
||
name: string;
|
||
field: FieldDescriptor;
|
||
value: unknown;
|
||
onChange: (name: string, value: unknown) => void;
|
||
/** Callback when a portableText editor is ready */
|
||
onEditorReady?: (editor: Editor) => void;
|
||
/** Minimal chrome - hides toolbar, fades labels, removes borders (distraction-free mode) */
|
||
minimal?: boolean;
|
||
/** Plugin block types available for insertion in Portable Text fields */
|
||
pluginBlocks?: PluginBlockDef[];
|
||
/** Callback when a block node requests sidebar space */
|
||
onBlockSidebarOpen?: (panel: BlockSidebarPanel) => void;
|
||
/** Callback when a block node closes its sidebar */
|
||
onBlockSidebarClose?: () => void;
|
||
/** Admin manifest for resolving sandboxed field widget elements */
|
||
manifest?: import("../lib/api/client.js").AdminManifest | null;
|
||
}
|
||
|
||
/**
|
||
* Render field based on type
|
||
*/
|
||
function FieldRenderer({
|
||
name,
|
||
field,
|
||
value,
|
||
onChange,
|
||
onEditorReady,
|
||
minimal,
|
||
pluginBlocks,
|
||
onBlockSidebarOpen,
|
||
onBlockSidebarClose,
|
||
manifest,
|
||
}: FieldRendererProps) {
|
||
const pluginAdmins = usePluginAdmins();
|
||
const label = field.label || name.charAt(0).toUpperCase() + name.slice(1);
|
||
const id = `field-${name}`;
|
||
const labelClass = minimal ? "text-kumo-subtle/50 text-xs font-normal" : undefined;
|
||
|
||
const handleChange = React.useCallback((v: unknown) => onChange(name, v), [onChange, name]);
|
||
|
||
// Check for plugin field widget override
|
||
if (field.widget) {
|
||
const sepIdx = field.widget.indexOf(":");
|
||
if (sepIdx <= 0) {
|
||
console.warn(
|
||
`[emdash] Field "${name}" has widget "${field.widget}" but it should use the format "pluginId:widgetName". Falling back to default editor.`,
|
||
);
|
||
}
|
||
if (sepIdx > 0) {
|
||
const pluginId = field.widget.slice(0, sepIdx);
|
||
const widgetName = field.widget.slice(sepIdx + 1);
|
||
// Trusted plugin: React component
|
||
const PluginField = pluginAdmins[pluginId]?.fields?.[widgetName] as
|
||
| React.ComponentType<{
|
||
value: unknown;
|
||
onChange: (value: unknown) => void;
|
||
label: string;
|
||
id: string;
|
||
required?: boolean;
|
||
options?: Array<{ value: string; label: string }>;
|
||
minimal?: boolean;
|
||
}>
|
||
| undefined;
|
||
if (typeof PluginField === "function") {
|
||
return (
|
||
<PluginFieldErrorBoundary fieldKind={field.kind}>
|
||
<PluginField
|
||
value={value}
|
||
onChange={handleChange}
|
||
label={label}
|
||
id={id}
|
||
required={field.required}
|
||
options={field.options}
|
||
minimal={minimal}
|
||
/>
|
||
</PluginFieldErrorBoundary>
|
||
);
|
||
}
|
||
// Sandboxed plugin: Block Kit elements from manifest
|
||
if (manifest) {
|
||
const pluginManifest = manifest.plugins[pluginId];
|
||
const widgetDef = pluginManifest?.fieldWidgets?.find((w) => w.name === widgetName);
|
||
if (widgetDef?.elements && widgetDef.elements.length > 0) {
|
||
return (
|
||
<PluginFieldErrorBoundary fieldKind={field.kind}>
|
||
<BlockKitFieldWidget
|
||
label={label}
|
||
elements={widgetDef.elements}
|
||
value={value}
|
||
onChange={handleChange}
|
||
/>
|
||
</PluginFieldErrorBoundary>
|
||
);
|
||
}
|
||
}
|
||
// Widget declared but plugin not found/active -- fall through to default
|
||
}
|
||
}
|
||
|
||
switch (field.kind) {
|
||
case "string":
|
||
return (
|
||
<Input
|
||
label={<span className={labelClass}>{label}</span>}
|
||
id={id}
|
||
value={typeof value === "string" ? value : ""}
|
||
onChange={(e) => handleChange(e.target.value)}
|
||
required={field.required}
|
||
className={
|
||
minimal
|
||
? "border-0 bg-transparent px-0 text-lg font-medium focus-visible:ring-0 focus-visible:ring-offset-0"
|
||
: undefined
|
||
}
|
||
/>
|
||
);
|
||
|
||
case "number":
|
||
return (
|
||
<Input
|
||
label={<span className={labelClass}>{label}</span>}
|
||
id={id}
|
||
type="number"
|
||
value={typeof value === "number" ? value : ""}
|
||
onChange={(e) => handleChange(Number(e.target.value))}
|
||
required={field.required}
|
||
/>
|
||
);
|
||
|
||
case "boolean":
|
||
return (
|
||
<Switch
|
||
label={label}
|
||
checked={typeof value === "boolean" ? value : false}
|
||
onCheckedChange={handleChange}
|
||
/>
|
||
);
|
||
|
||
case "portableText": {
|
||
const labelId = `${id}-label`;
|
||
return (
|
||
<div>
|
||
{!minimal && (
|
||
<span
|
||
id={labelId}
|
||
className={cn("text-sm font-medium leading-none text-kumo-default", labelClass)}
|
||
>
|
||
{label}
|
||
</span>
|
||
)}
|
||
<PortableTextEditor
|
||
value={Array.isArray(value) ? value : []}
|
||
onChange={handleChange}
|
||
placeholder={`Enter ${label.toLowerCase()}...`}
|
||
aria-labelledby={labelId}
|
||
pluginBlocks={pluginBlocks}
|
||
onEditorReady={onEditorReady}
|
||
minimal={minimal}
|
||
onBlockSidebarOpen={onBlockSidebarOpen}
|
||
onBlockSidebarClose={onBlockSidebarClose}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case "richText":
|
||
// For richText (markdown), use InputArea
|
||
return (
|
||
<InputArea
|
||
label={label}
|
||
id={id}
|
||
value={typeof value === "string" ? value : ""}
|
||
onChange={(e) => handleChange(e.target.value)}
|
||
rows={10}
|
||
placeholder="Enter markdown content..."
|
||
/>
|
||
);
|
||
|
||
case "select": {
|
||
const selectItems: Record<string, string> = {};
|
||
for (const opt of field.options ?? []) {
|
||
selectItems[opt.value] = opt.label;
|
||
}
|
||
return (
|
||
<Select
|
||
label={label}
|
||
value={typeof value === "string" ? value : ""}
|
||
onValueChange={(v) => handleChange(v ?? "")}
|
||
items={selectItems}
|
||
>
|
||
{field.options?.map((opt) => (
|
||
<Select.Option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</Select.Option>
|
||
))}
|
||
</Select>
|
||
);
|
||
}
|
||
|
||
case "datetime":
|
||
return (
|
||
<Input
|
||
label={label}
|
||
id={id}
|
||
type="datetime-local"
|
||
value={typeof value === "string" ? value : ""}
|
||
onChange={(e) => handleChange(e.target.value)}
|
||
required={field.required}
|
||
/>
|
||
);
|
||
|
||
case "image": {
|
||
// value is either an ImageFieldValue object, a legacy string URL, or undefined
|
||
const imageValue =
|
||
value != null && typeof value === "object" ? (value as ImageFieldValue) : undefined;
|
||
return (
|
||
<ImageFieldRenderer
|
||
label={label}
|
||
value={imageValue}
|
||
onChange={handleChange}
|
||
required={field.required}
|
||
/>
|
||
);
|
||
}
|
||
|
||
default:
|
||
// Default to text input
|
||
return (
|
||
<Input
|
||
label={label}
|
||
id={id}
|
||
value={typeof value === "string" ? value : ""}
|
||
onChange={(e) => handleChange(e.target.value)}
|
||
required={field.required}
|
||
/>
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Image field value - matches emdash's MediaValue type
|
||
*/
|
||
interface ImageFieldValue {
|
||
id: string;
|
||
/** Provider ID (e.g., "local", "cloudflare-images") */
|
||
provider?: string;
|
||
/** Direct URL for local media or legacy data */
|
||
src?: string;
|
||
/** Preview URL for admin display (separate from src used for rendering) */
|
||
previewUrl?: string;
|
||
alt?: string;
|
||
width?: number;
|
||
height?: number;
|
||
/** Provider-specific metadata */
|
||
meta?: Record<string, unknown>;
|
||
}
|
||
|
||
/**
|
||
* Image field with media picker
|
||
*
|
||
* Stores full image metadata including dimensions for responsive images.
|
||
* Handles backwards compatibility with legacy string URLs.
|
||
*/
|
||
interface ImageFieldRendererProps {
|
||
label: string;
|
||
value: ImageFieldValue | string | undefined;
|
||
onChange: (value: ImageFieldValue | undefined) => void;
|
||
required?: boolean;
|
||
}
|
||
|
||
function ImageFieldRenderer({ label, value, onChange, required }: ImageFieldRendererProps) {
|
||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||
// Normalize value to get display URL (handles both object and legacy string)
|
||
// Prefer previewUrl for admin display, fall back to src, then derive from storageKey/id
|
||
const displayUrl =
|
||
typeof value === "string"
|
||
? value
|
||
: value?.previewUrl ||
|
||
value?.src ||
|
||
(value && (!value.provider || value.provider === "local")
|
||
? `/_emdash/api/media/file/${typeof value.meta?.storageKey === "string" ? value.meta.storageKey : value.id}`
|
||
: undefined);
|
||
|
||
const handleSelect = (item: MediaItem) => {
|
||
const isLocalProvider = !item.provider || item.provider === "local";
|
||
|
||
onChange({
|
||
id: item.id,
|
||
provider: item.provider || "local",
|
||
// Local media derives URLs from meta.storageKey at display time — no src needed
|
||
// External providers cache a preview URL for admin display
|
||
previewUrl: isLocalProvider ? undefined : item.url,
|
||
alt: item.alt || "",
|
||
width: item.width,
|
||
height: item.height,
|
||
meta: isLocalProvider ? { ...item.meta, storageKey: item.storageKey } : item.meta,
|
||
});
|
||
};
|
||
|
||
const handleRemove = () => {
|
||
onChange(undefined);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<Label>{label}</Label>
|
||
{displayUrl ? (
|
||
<div className="mt-2 relative group">
|
||
<img src={displayUrl} alt="" className="max-h-48 rounded-lg border object-cover" />
|
||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||
<Button type="button" size="sm" variant="secondary" onClick={() => setPickerOpen(true)}>
|
||
Change
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
shape="square"
|
||
variant="destructive"
|
||
className="h-8 w-8"
|
||
onClick={handleRemove}
|
||
aria-label="Remove image"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
className="mt-2 w-full h-32 border-dashed"
|
||
onClick={() => setPickerOpen(true)}
|
||
>
|
||
<div className="flex flex-col items-center gap-2 text-kumo-subtle">
|
||
<ImageIcon className="h-8 w-8" />
|
||
<span>Select image</span>
|
||
</div>
|
||
</Button>
|
||
)}
|
||
<MediaPickerModal
|
||
open={pickerOpen}
|
||
onOpenChange={setPickerOpen}
|
||
onSelect={handleSelect}
|
||
mimeTypeFilter="image/"
|
||
title={`Select ${label}`}
|
||
/>
|
||
{required && !displayUrl && (
|
||
<p className="text-sm text-kumo-danger mt-1">This field is required</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Author selector component for editors and above
|
||
*/
|
||
interface AuthorSelectorProps {
|
||
authorId: string | null;
|
||
users: UserListItem[];
|
||
onChange?: (authorId: string | null) => void;
|
||
}
|
||
|
||
interface BylineCreditsEditorProps {
|
||
credits: BylineCreditInput[];
|
||
bylines: BylineSummary[];
|
||
onChange: (bylines: BylineCreditInput[]) => void;
|
||
onQuickCreate?: (input: { slug: string; displayName: string }) => Promise<BylineSummary>;
|
||
onQuickEdit?: (
|
||
bylineId: string,
|
||
input: { slug: string; displayName: string },
|
||
) => Promise<BylineSummary>;
|
||
}
|
||
|
||
function BylineCreditsEditor({
|
||
credits,
|
||
bylines,
|
||
onChange,
|
||
onQuickCreate,
|
||
onQuickEdit,
|
||
}: BylineCreditsEditorProps) {
|
||
const [selectedBylineId, setSelectedBylineId] = React.useState("");
|
||
const [quickName, setQuickName] = React.useState("");
|
||
const [quickSlug, setQuickSlug] = React.useState("");
|
||
const [quickError, setQuickError] = React.useState<string | null>(null);
|
||
const [isCreating, setIsCreating] = React.useState(false);
|
||
const [editBylineId, setEditBylineId] = React.useState<string | null>(null);
|
||
const [editName, setEditName] = React.useState("");
|
||
const [editSlug, setEditSlug] = React.useState("");
|
||
const [editError, setEditError] = React.useState<string | null>(null);
|
||
const [isEditing, setIsEditing] = React.useState(false);
|
||
|
||
const bylineMap = React.useMemo(() => new Map(bylines.map((b) => [b.id, b])), [bylines]);
|
||
|
||
const availableToAdd = bylines.filter((b) => !credits.some((c) => c.bylineId === b.id));
|
||
|
||
const move = (index: number, direction: -1 | 1) => {
|
||
const target = index + direction;
|
||
if (target < 0 || target >= credits.length) return;
|
||
const next = [...credits];
|
||
const [moved] = next.splice(index, 1);
|
||
if (!moved) return;
|
||
next.splice(target, 0, moved);
|
||
onChange(next);
|
||
};
|
||
|
||
const resetQuickCreate = () => {
|
||
setQuickName("");
|
||
setQuickSlug("");
|
||
setQuickError(null);
|
||
};
|
||
|
||
const openEditByline = (byline: BylineSummary) => {
|
||
setEditBylineId(byline.id);
|
||
setEditName(byline.displayName);
|
||
setEditSlug(byline.slug);
|
||
setEditError(null);
|
||
};
|
||
|
||
const resetQuickEdit = () => {
|
||
setEditBylineId(null);
|
||
setEditName("");
|
||
setEditSlug("");
|
||
setEditError(null);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex gap-2">
|
||
<select
|
||
value={selectedBylineId}
|
||
onChange={(e) => setSelectedBylineId(e.target.value)}
|
||
className="w-full rounded border bg-kumo-base px-3 py-2 text-sm"
|
||
>
|
||
<option value="">Select byline...</option>
|
||
{availableToAdd.map((b) => (
|
||
<option key={b.id} value={b.id}>
|
||
{b.displayName}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
onClick={() => {
|
||
if (!selectedBylineId) return;
|
||
onChange([...credits, { bylineId: selectedBylineId, roleLabel: null }]);
|
||
setSelectedBylineId("");
|
||
}}
|
||
disabled={!selectedBylineId}
|
||
>
|
||
Add
|
||
</Button>
|
||
</div>
|
||
|
||
{credits.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{credits.map((credit, index) => {
|
||
const byline = bylineMap.get(credit.bylineId);
|
||
if (!byline) return null;
|
||
return (
|
||
<div key={`${credit.bylineId}-${index}`} className="rounded border p-2 space-y-2">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div>
|
||
<p className="text-sm font-medium">{byline.displayName}</p>
|
||
<p className="text-xs text-kumo-subtle">{byline.slug}</p>
|
||
</div>
|
||
<div className="flex gap-1">
|
||
<Button type="button" variant="ghost" size="sm" onClick={() => move(index, -1)}>
|
||
Up
|
||
</Button>
|
||
<Button type="button" variant="ghost" size="sm" onClick={() => move(index, 1)}>
|
||
Down
|
||
</Button>
|
||
{onQuickEdit && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => openEditByline(byline)}
|
||
>
|
||
Edit
|
||
</Button>
|
||
)}
|
||
<Button
|
||
type="button"
|
||
variant="destructive"
|
||
size="sm"
|
||
onClick={() => onChange(credits.filter((_, i) => i !== index))}
|
||
>
|
||
Remove
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<Input
|
||
label="Role label"
|
||
value={credit.roleLabel ?? ""}
|
||
onChange={(e) => {
|
||
const next = [...credits];
|
||
const current = next[index];
|
||
if (!current) return;
|
||
next[index] = {
|
||
...current,
|
||
roleLabel: e.target.value || null,
|
||
};
|
||
onChange(next);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-kumo-subtle">No bylines selected.</p>
|
||
)}
|
||
|
||
{onQuickCreate && (
|
||
<Dialog.Root>
|
||
<Dialog.Trigger
|
||
render={(p) => (
|
||
<Button {...p} type="button" variant="secondary">
|
||
Quick create byline
|
||
</Button>
|
||
)}
|
||
/>
|
||
<Dialog className="p-6" size="sm">
|
||
<Dialog.Title className="text-lg font-semibold">Create byline</Dialog.Title>
|
||
<div className="mt-4 space-y-3">
|
||
<Input
|
||
label="Display name"
|
||
value={quickName}
|
||
onChange={(e) => {
|
||
setQuickName(e.target.value);
|
||
if (!quickSlug) setQuickSlug(slugify(e.target.value));
|
||
}}
|
||
/>
|
||
<Input
|
||
label="Slug"
|
||
value={quickSlug}
|
||
onChange={(e) => setQuickSlug(e.target.value)}
|
||
/>
|
||
{quickError && <p className="text-sm text-kumo-danger">{quickError}</p>}
|
||
</div>
|
||
<div className="mt-6 flex justify-end gap-2">
|
||
<Dialog.Close
|
||
render={(p) => (
|
||
<Button {...p} variant="secondary" onClick={resetQuickCreate}>
|
||
Cancel
|
||
</Button>
|
||
)}
|
||
/>
|
||
<Button
|
||
type="button"
|
||
disabled={!quickName || !quickSlug || isCreating}
|
||
onClick={async () => {
|
||
setQuickError(null);
|
||
setIsCreating(true);
|
||
try {
|
||
const created = await onQuickCreate({
|
||
displayName: quickName,
|
||
slug: quickSlug,
|
||
});
|
||
onChange([...credits, { bylineId: created.id, roleLabel: null }]);
|
||
resetQuickCreate();
|
||
} catch (err) {
|
||
setQuickError(err instanceof Error ? err.message : "Failed to create byline");
|
||
} finally {
|
||
setIsCreating(false);
|
||
}
|
||
}}
|
||
>
|
||
{isCreating ? "Creating..." : "Create"}
|
||
</Button>
|
||
</div>
|
||
</Dialog>
|
||
</Dialog.Root>
|
||
)}
|
||
|
||
{onQuickEdit && editBylineId && (
|
||
<Dialog.Root open onOpenChange={(open) => (!open ? resetQuickEdit() : undefined)}>
|
||
<Dialog className="p-6" size="sm">
|
||
<Dialog.Title className="text-lg font-semibold">Edit byline</Dialog.Title>
|
||
<div className="mt-4 space-y-3">
|
||
<Input
|
||
label="Display name"
|
||
value={editName}
|
||
onChange={(e) => {
|
||
setEditName(e.target.value);
|
||
if (!editSlug) setEditSlug(slugify(e.target.value));
|
||
}}
|
||
/>
|
||
<Input label="Slug" value={editSlug} onChange={(e) => setEditSlug(e.target.value)} />
|
||
{editError && <p className="text-sm text-kumo-danger">{editError}</p>}
|
||
</div>
|
||
<div className="mt-6 flex justify-end gap-2">
|
||
<Button type="button" variant="secondary" onClick={resetQuickEdit}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
disabled={!editName || !editSlug || isEditing}
|
||
onClick={async () => {
|
||
setEditError(null);
|
||
setIsEditing(true);
|
||
try {
|
||
await onQuickEdit(editBylineId, {
|
||
displayName: editName,
|
||
slug: editSlug,
|
||
});
|
||
resetQuickEdit();
|
||
} catch (err) {
|
||
setEditError(err instanceof Error ? err.message : "Failed to update byline");
|
||
} finally {
|
||
setIsEditing(false);
|
||
}
|
||
}}
|
||
>
|
||
{isEditing ? "Saving..." : "Save"}
|
||
</Button>
|
||
</div>
|
||
</Dialog>
|
||
</Dialog.Root>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AuthorSelector({ authorId, users, onChange }: AuthorSelectorProps) {
|
||
const currentAuthor = users.find((u) => u.id === authorId);
|
||
|
||
const authorItems: Record<string, string> = { unassigned: "Unassigned" };
|
||
for (const user of users) {
|
||
authorItems[user.id] = user.name || user.email;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
<Select
|
||
value={authorId || "unassigned"}
|
||
onValueChange={(value) =>
|
||
onChange?.(value === "unassigned" || value === null ? null : value)
|
||
}
|
||
items={authorItems}
|
||
>
|
||
<Select.Option value="unassigned">
|
||
<span className="text-kumo-subtle">Unassigned</span>
|
||
</Select.Option>
|
||
{users.map((user) => (
|
||
<Select.Option key={user.id} value={user.id}>
|
||
<span className="flex items-center gap-2">
|
||
{user.name || user.email}
|
||
{user.name && <span className="text-xs text-kumo-subtle">({user.email})</span>}
|
||
</span>
|
||
</Select.Option>
|
||
))}
|
||
</Select>
|
||
{currentAuthor && <p className="text-xs text-kumo-subtle">{currentAuthor.email}</p>}
|
||
</div>
|
||
);
|
||
}
|