diff --git a/.changeset/id-batch-8.md b/.changeset/id-batch-8.md new file mode 100644 index 0000000000..a3cda267e8 --- /dev/null +++ b/.changeset/id-batch-8.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Updates Indonesian (`id`) translations in the admin UI. diff --git a/packages/admin/src/locales/id/messages.po b/packages/admin/src/locales/id/messages.po index 1d6813efaa..d11bc80dc1 100644 --- a/packages/admin/src/locales/id/messages.po +++ b/packages/admin/src/locales/id/messages.po @@ -439,7 +439,7 @@ msgstr "{0} dihapus dari postingan ini." #. placeholder {0}: SYSTEM_FIELDS.length #. placeholder {1}: fields.length -#. placeholder {2}: import { Badge, Button, Checkbox, Input, InputArea, Label, Select, Switch } from "@cloudflare/kumo"; import { DndContext, closestCenter, type DragEndEvent, KeyboardSensor, PointerSensor, useSensor, useSensors, } from "@dnd-kit/core"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { MessageDescriptor } from "@lingui/core"; import { msg, plural } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; import { Plus, DotsSixVertical, Pencil, Trash, Database, FileText } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import * as React from "react"; import { fetchCollections, fetchRelations } from "../lib/api"; import type { SchemaCollectionWithFields, SchemaField, CreateFieldInput, CreateCollectionInput, UpdateCollectionInput, } from "../lib/api"; import type { CreateRelationInput, RelationDef, UpdateRelationInput, } from "../lib/api/relations.js"; import { cn, slugifyIdentifier } from "../lib/utils"; import { ArrowPrev } from "./ArrowIcons.js"; import { ConfirmDialog } from "./ConfirmDialog"; import { getMutationError } from "./DialogError"; import { EditorHeader } from "./EditorHeader"; import { FieldEditor } from "./FieldEditor"; import { RelationImpact } from "./RelationImpact.js"; import { RelationsPanel } from "./RelationsPanel.js"; import { RouterLinkButton } from "./RouterLinkButton.js"; import { SaveButton } from "./SaveButton"; const MULTIPLE_PLACEHOLDERS_IN_SEGMENT = /\{\w+\}[^/]*\{\w+\}/; export interface ContentTypeEditorProps { collection?: SchemaCollectionWithFields; isNew?: boolean; isSaving?: boolean; onSave: (input: CreateCollectionInput | UpdateCollectionInput) => void; /** Resolves once the field is saved; rejects with the server's message. */ onAddField?: (input: CreateFieldInput) => Promise; /** Resolves once the field is saved; rejects with the server's message. */ onUpdateField?: (fieldSlug: string, input: CreateFieldInput) => Promise; /** `deleteRelation` also removes the relationship a reference field views, * its links, and the field on the other end. */ onDeleteField?: (fieldSlug: string, options?: { deleteRelation?: boolean }) => void; onReorderFields?: (fieldSlugs: string[]) => void; /** Resolves with the new relation; rejects with the server's message. */ onCreateRelation?: (input: CreateRelationInput) => Promise; /** Resolves once the relation is saved; rejects with the server's message. */ onUpdateRelation?: (id: string, input: UpdateRelationInput) => Promise; /** Also removes the relation's links and the reference fields bound to it. */ onDeleteRelation?: (id: string) => Promise; isDeletingRelation?: boolean; deleteRelationError?: unknown; } interface SupportOptionDef { value: string; label: MessageDescriptor; description: MessageDescriptor; } const MODERATION_OPTIONS: Record<"all" | "first_time" | "none", MessageDescriptor> = { all: msg`All comments require approval`, first_time: msg`First-time commenters only`, none: msg`No moderation (auto-approve all)`, }; const SUPPORT_OPTIONS: SupportOptionDef[] = [ { value: "drafts", label: msg`Drafts`, description: msg`Save content as draft before publishing`, }, { value: "revisions", label: msg`Revisions`, description: msg`Track content history`, }, { value: "preview", label: msg`Preview`, description: msg`Preview content before publishing`, }, { value: "search", label: msg`Search`, description: msg`Enable full-text search on this collection`, }, ]; /** * System fields that exist on every collection * These are created automatically and cannot be modified */ interface SystemFieldDef { slug: string; label: MessageDescriptor; type: string; description: MessageDescriptor; } const SYSTEM_FIELDS: SystemFieldDef[] = [ { slug: "id", label: msg`ID`, type: "text", description: msg`Unique identifier (ULID)`, }, { slug: "slug", label: msg`Slug`, type: "text", description: msg`URL-friendly identifier`, }, { slug: "status", label: msg`Status`, type: "text", description: msg`draft, published, or archived`, }, { slug: "created_at", label: msg`Created At`, type: "datetime", description: msg`When the entry was created`, }, { slug: "updated_at", label: msg`Updated At`, type: "datetime", description: msg`When the entry was last modified`, }, { slug: "published_at", label: msg`Published At`, type: "datetime", description: msg`When the entry was published`, }, ]; /** * Content Type editor for creating/editing collections */ export function ContentTypeEditor({ collection, isNew, isSaving, onSave, onAddField, onUpdateField, onDeleteField, onReorderFields, onCreateRelation, onUpdateRelation, onDeleteRelation, isDeletingRelation, deleteRelationError, }: ContentTypeEditorProps) { const { t } = useLingui(); const _navigate = useNavigate(); // Form state const [slug, setSlug] = React.useState(collection?.slug ?? ""); const [label, setLabel] = React.useState(collection?.label ?? ""); const [labelSingular, setLabelSingular] = React.useState(collection?.labelSingular ?? ""); const [description, setDescription] = React.useState(collection?.description ?? ""); const [urlPattern, setUrlPattern] = React.useState(collection?.urlPattern ?? ""); const [routable, setRoutable] = React.useState(collection?.routable ?? true); const [editLocking, setEditLocking] = React.useState(collection?.editLocking ?? true); const [icon, setIcon] = React.useState(collection?.icon ?? ""); const [group, setGroup] = React.useState(collection?.group ?? ""); const [hidden, setHidden] = React.useState(collection?.hidden ?? false); const [quickCreate, setQuickCreate] = React.useState(collection?.admin?.quickCreate ?? true); // SEO is managed via the separate `hasSeo` field; strip any legacy "seo" entry // so it isn't sent back on save (the API enum rejects it). const [supports, setSupports] = React.useState( (collection?.supports ?? ["drafts", "revisions"]).filter((s) => s !== "seo"), ); // SEO state const [hasSeo, setHasSeo] = React.useState(collection?.hasSeo ?? false); // Comment settings state const [commentsEnabled, setCommentsEnabled] = React.useState( collection?.commentsEnabled ?? false, ); const [commentsModeration, setCommentsModeration] = React.useState<"all" | "first_time" | "none">( collection?.commentsModeration ?? "first_time", ); const [commentsClosedAfterDays, setCommentsClosedAfterDays] = React.useState( collection?.commentsClosedAfterDays ?? 90, ); const [commentsAutoApproveUsers, setCommentsAutoApproveUsers] = React.useState( collection?.commentsAutoApproveUsers ?? true, ); // Field editor state const [fieldEditorOpen, setFieldEditorOpen] = React.useState(false); const [editingField, setEditingField] = React.useState(); const [fieldSaving, setFieldSaving] = React.useState(false); const [fieldSaveError, setFieldSaveError] = React.useState(null); const [deleteFieldTarget, setDeleteFieldTarget] = React.useState(null); // Checked by default: deleting a reference field almost always means the // relationship it views is finished too. const [deleteFieldRelation, setDeleteFieldRelation] = React.useState(true); const urlPatternChanged = urlPattern !== (collection?.urlPattern ?? ""); const urlPatternSharesSegment = urlPatternChanged && MULTIPLE_PLACEHOLDERS_IN_SEGMENT.test(urlPattern); const urlPatternValid = !urlPattern || (urlPattern.includes("{slug}") && !urlPatternSharesSegment); const slugError = isNew && !slug && label.trim().length > 0 ? t`A slug cannot be generated from this label. Type one manually using lowercase letters, numbers, and underscores.` : undefined; // Track whether form has unsaved changes const hasChanges = React.useMemo(() => { if (isNew) return slug && label; if (!collection) return false; return ( label !== collection.label || labelSingular !== (collection.labelSingular ?? "") || description !== (collection.description ?? "") || urlPattern !== (collection.urlPattern ?? "") || routable !== (collection.routable ?? true) || editLocking !== (collection.editLocking ?? true) || icon !== (collection.icon ?? "") || group !== (collection.group ?? "") || hidden !== (collection.hidden ?? false) || quickCreate !== (collection.admin?.quickCreate ?? true) || JSON.stringify([...supports].toSorted()) !== JSON.stringify(collection.supports.filter((s) => s !== "seo").toSorted()) || hasSeo !== collection.hasSeo || commentsEnabled !== collection.commentsEnabled || commentsModeration !== collection.commentsModeration || commentsClosedAfterDays !== collection.commentsClosedAfterDays || commentsAutoApproveUsers !== collection.commentsAutoApproveUsers ); }, [ isNew, collection, slug, label, labelSingular, description, urlPattern, routable, editLocking, icon, group, hidden, quickCreate, supports, hasSeo, commentsEnabled, commentsModeration, commentsClosedAfterDays, commentsAutoApproveUsers, ]); // Auto-generate slug from plural label const handleLabelChange = (value: string) => { setLabel(value); if (isNew) { setSlug(slugifyIdentifier(value)); } }; // Auto-generate plural label (and slug) from singular label const handleSingularLabelChange = (value: string) => { setLabelSingular(value); if (isNew) { const pluralLabel = value ? `${value}s` : ""; handleLabelChange(pluralLabel); } }; const handleSupportToggle = (value: string) => { setSupports((prev) => prev.includes(value) ? prev.filter((s) => s !== value) : [...prev, value], ); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (isNew) { onSave({ slug, label, labelSingular: labelSingular || undefined, description: description || undefined, urlPattern: urlPattern || undefined, routable, editLocking, icon: icon.trim() || undefined, group: group.trim() || undefined, hidden, admin: quickCreate ? undefined : { quickCreate: false }, supports, hasSeo, }); } else { onSave({ label, labelSingular: labelSingular || undefined, description: description || undefined, urlPattern: urlPattern || undefined, routable, editLocking, icon: icon.trim(), group: group.trim() || null, hidden, admin: quickCreate !== (collection?.admin?.quickCreate ?? true) ? { ...collection?.admin, quickCreate: quickCreate ? undefined : false } : undefined, supports, hasSeo, commentsEnabled, commentsModeration, commentsClosedAfterDays, commentsAutoApproveUsers, }); } }; const handleFieldSave = async (input: CreateFieldInput) => { setFieldSaving(true); setFieldSaveError(null); try { if (editingField) { await onUpdateField?.(editingField.slug, input); } else { await onAddField?.(input); } setFieldEditorOpen(false); setEditingField(undefined); } catch (err) { setFieldSaveError(getMutationError(err)); } finally { setFieldSaving(false); } }; const requestDeleteField = (field: SchemaField) => { setDeleteFieldRelation(true); setDeleteFieldTarget(field); }; const handleEditField = (field: SchemaField) => { if (field.unsupportedType) return; setEditingField(field); setFieldSaveError(null); setFieldEditorOpen(true); }; const handleAddField = () => { setEditingField(undefined); setFieldSaveError(null); setFieldEditorOpen(true); }; const isFromCode = collection?.source === "code"; const fields = collection?.fields ?? []; const { data: relations = [], isLoading: relationsLoading } = useQuery({ queryKey: ["relations"], queryFn: () => fetchRelations(), }); const { data: allCollections = [] } = useQuery({ queryKey: ["schema", "collections"], queryFn: fetchCollections, }); const targetRelationSlug = deleteFieldTarget?.validation?.relation; const targetRelation = relations.find((rel) => rel.slug === targetRelationSlug); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = fields.findIndex((f) => f.id === active.id); const newIndex = fields.findIndex((f) => f.id === over.id); if (oldIndex === -1 || newIndex === -1) return; const reordered = arrayMove(fields, oldIndex, newIndex); onReorderFields?.(reordered.map((f) => f.slug)); }; return (
} /> } actions={ !isFromCode && !isNew ? ( ) : null } >

{isNew ? t`New Content Type` : collection?.label}

{!isNew && (

{collection?.slug} {isFromCode && {t`Defined in code`}}

)}
{isFromCode && (

{t`This collection is defined in code. Some settings cannot be changed here. Edit your live.config.ts file to modify the schema.`}

)}
{/* Settings form */}

{t`Settings`}

handleSingularLabelChange(e.target.value)} placeholder={t`Post`} disabled={isFromCode} /> handleLabelChange(e.target.value)} placeholder={t`Posts`} disabled={isFromCode} /> {isNew && (
setSlug(e.target.value)} placeholder="posts" disabled={!isNew} error={slugError} />

{t`Used in URLs and API endpoints`}

)} setDescription(e.target.value)} placeholder={t`A brief description of this content type`} rows={3} disabled={isFromCode} /> {t`Routable`}

{t`Require a slug before content can be published`}

} /> {t`Edit locking`}

{t`Hold an entry while someone is editing it, and refuse other writers`}

} />
setUrlPattern(e.target.value)} placeholder={`/${slug === "pages" ? "" : `${slug}/`}{slug}`} disabled={isFromCode} /> {urlPattern && !urlPattern.includes("{slug}") && (

{t`Pattern must include a ${"{slug}"} placeholder`}

)} {urlPatternSharesSegment && (

{t`Each path segment can contain at most one placeholder, e.g. ${"/{year}/{slug}"}`}

)}

{t`Pattern for generating URLs, e.g. /blog/${"{slug}"}. Tokens: ${"{slug}"}, ${"{id}"}, and date tokens ${"{year}"}/${"{month}"}/${"{day}"} (also ${"{hour}"}/${"{minute}"}/${"{second}"}) from the publish date — e.g. ${"/{year}/{month}/{day}/{slug}.html"} for WordPress-style permalinks.`}

setGroup(e.target.value)} placeholder={t`Calendar`} disabled={isFromCode} />

{t`Content types with the same group share a collapsible folder in the sidebar`}

setIcon(e.target.value)} disabled={isFromCode} />

{t`Phosphor icon name, e.g. calendar-blank or trophy`}

{t`Hide from navigation`}

{t`Removes the sidebar entry, command palette link, and dashboard quick action; entries stay reachable by URL, API, and plugins`}

} /> {t`Quick action on the dashboard`}

{t`Shows a button for creating a new entry`}

} />
{SUPPORT_OPTIONS.map((option) => (
handleSupportToggle(option.value)} disabled={isFromCode} label={
{t(option.label)}

{t(option.description)}

} />
))}
{/* SEO toggle */}
setHasSeo(checked)} disabled={isFromCode} label={
{t`SEO`}

{t`Add SEO metadata fields (title, description, image) and include in sitemap`}

} />
{/* Comments settings — only for existing collections */} {!isNew && (

{t`Comments`}

setCommentsEnabled(checked)} disabled={isFromCode} label={
{t`Enable comments`}

{t`Allow visitors to leave comments on this collection's content`}

} /> {commentsEnabled && ( <> { const parsed = Number.parseInt(e.target.value, 10); setCommentsClosedAfterDays(Number.isNaN(parsed) ? 0 : Math.max(0, parsed)); }} disabled={isFromCode} />

{t`Set to 0 to never close comments automatically.`}

setCommentsAutoApproveUsers(checked)} disabled={isFromCode} label={
{t`Auto-approve authenticated users`}

{t`Comments from logged-in CMS users are approved automatically`}

} /> )}
)} {!isFromCode && (isNew ? ( ) : ( ))} {/* Fields and relations - only shown for existing collections */} {!isNew && (

{t`Fields`}

{SYSTEM_FIELDS.length} system + {fields.length} custom{" "} {plural(fields.length, { one: "field", other: "fields" })}

{!isFromCode && ( )}
{/* System fields - always shown */}
{t`System Fields`}
{SYSTEM_FIELDS.map((field) => ( ))}
{/* Custom fields */} {fields.length === 0 ? (

{t`No custom fields yet`}

{t`Add fields to define the structure of your content`}

{!isFromCode && ( )}
) : ( <>
{t`Custom Fields`}
f.id)} strategy={verticalListSortingStrategy} >
{fields.map((field) => ( handleEditField(field)} onDelete={() => requestDeleteField(field)} /> ))}
)}
{collection && ( )}
)} {/* Field editor dialog */} { setFieldEditorOpen(nextOpen); if (!nextOpen) setFieldSaveError(null); }} field={editingField} onSave={handleFieldSave} isSaving={fieldSaving} saveError={fieldSaveError} collectionSlug={collection?.slug} onCreateRelation={onCreateRelation} /> setDeleteFieldTarget(null)} title={t`Delete Field?`} description={ deleteFieldTarget ? t`Are you sure you want to delete the "${deleteFieldTarget.label}" field?` : "" } confirmLabel={t`Delete`} pendingLabel={t`Deleting...`} isPending={false} error={null} onConfirm={() => { if (deleteFieldTarget) { onDeleteField?.( deleteFieldTarget.slug, targetRelation ? { deleteRelation: deleteFieldRelation } : undefined, ); setDeleteFieldTarget(null); } }} > {targetRelation && (
setDeleteFieldRelation(checked === true)} label={t`Also delete the relationship this field uses`} /> {deleteFieldRelation && collection && (

{t`This also removes:`}

)}
)}
); } interface FieldRowProps { field: SchemaField; isFromCode?: boolean; onEdit: () => void; onDelete: () => void; } function FieldRow({ field, isFromCode, onEdit, onDelete }: FieldRowProps) { const { t } = useLingui(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.id, disabled: isFromCode, }); const style = { transform: CSS.Transform.toString(transform), transition }; return (
{!isFromCode && ( )}
{field.label} {field.slug}
{field.unsupportedType?.type ?? field.type} {field.unsupportedType && {t`Unsupported`}} {field.required && {t`Required`}} {field.unique && {t`Unique`}} {field.searchable && {t`Searchable`}}
{!isFromCode && (
)}
); } interface SystemFieldInfo { slug: string; label: MessageDescriptor; type: string; description: MessageDescriptor; } function SystemFieldRow({ field }: { field: SystemFieldInfo }) { const { t } = useLingui(); return (
{/* Spacer for alignment with draggable fields */}
{t(field.label)} {field.slug} {t`System`}

{t(field.description)}

); } +#. placeholder {2}: import { Badge, Button, Checkbox, Input, InputArea, Label, Select, Switch } from "@cloudflare/kumo"; import { DndContext, closestCenter, type DragEndEvent, KeyboardSensor, PointerSensor, useSensor, useSensors, } from "@dnd-kit/core"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { MessageDescriptor } from "@lingui/core"; import { msg, plural } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; import { Plus, DotsSixVertical, Pencil, Trash, Database, FileText } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import * as React from "react"; import { fetchCollections, fetchRelations } from "../lib/api"; import type { SchemaCollectionWithFields, SchemaField, CreateFieldInput, CreateCollectionInput, UpdateCollectionInput, } from "../lib/api"; import type { CreateRelationInput, RelationDef, UpdateRelationInput, } from "../lib/api/relations.js"; import { cn, slugifyIdentifier } from "../lib/utils"; import { ArrowPrev } from "./ArrowIcons.js"; import { ConfirmDialog } from "./ConfirmDialog"; import { getMutationError } from "./DialogError"; import { EditorHeader } from "./EditorHeader"; import { FieldEditor } from "./FieldEditor"; import { RelationImpact } from "./RelationImpact.js"; import { RelationsPanel } from "./RelationsPanel.js"; import { RouterLinkButton } from "./RouterLinkButton.js"; import { SaveButton } from "./SaveButton"; const MULTIPLE_PLACEHOLDERS_IN_SEGMENT = /\{\w+\}[^/]*\{\w+\}/; export interface ContentTypeEditorProps { collection?: SchemaCollectionWithFields; isNew?: boolean; isSaving?: boolean; onSave: (input: CreateCollectionInput | UpdateCollectionInput) => void; /** Resolves once the field is saved; rejects with the server's message. */ onAddField?: (input: CreateFieldInput) => Promise; /** Resolves once the field is saved; rejects with the server's message. */ onUpdateField?: (fieldSlug: string, input: CreateFieldInput) => Promise; /** `deleteRelation` also removes the relationship a reference field views, * its links, and the field on the other end. */ onDeleteField?: (fieldSlug: string, options?: { deleteRelation?: boolean }) => void; onReorderFields?: (fieldSlugs: string[]) => void; /** Resolves with the new relation; rejects with the server's message. */ onCreateRelation?: (input: CreateRelationInput) => Promise; /** Resolves once the relation is saved; rejects with the server's message. */ onUpdateRelation?: (id: string, input: UpdateRelationInput) => Promise; /** Also removes the relation's links and the reference fields bound to it. */ onDeleteRelation?: (id: string) => Promise; isDeletingRelation?: boolean; deleteRelationError?: unknown; } interface SupportOptionDef { value: string; label: MessageDescriptor; description: MessageDescriptor; } const MODERATION_OPTIONS: Record<"all" | "first_time" | "none", MessageDescriptor> = { all: msg`All comments require approval`, first_time: msg`First-time commenters only`, none: msg`No moderation (auto-approve all)`, }; const SUPPORT_OPTIONS: SupportOptionDef[] = [ { value: "drafts", label: msg`Drafts`, description: msg`Save content as draft before publishing`, }, { value: "revisions", label: msg`Revisions`, description: msg`Track content history`, }, { value: "preview", label: msg`Preview`, description: msg`Preview content before publishing`, }, { value: "search", label: msg`Search`, description: msg`Enable full-text search on this collection`, }, ]; /** * System fields that exist on every collection * These are created automatically and cannot be modified */ interface SystemFieldDef { slug: string; label: MessageDescriptor; type: string; description: MessageDescriptor; } const SYSTEM_FIELDS: SystemFieldDef[] = [ { slug: "id", label: msg`ID`, type: "text", description: msg`Unique identifier (ULID)`, }, { slug: "slug", label: msg`Slug`, type: "text", description: msg`URL-friendly identifier`, }, { slug: "status", label: msg`Status`, type: "text", description: msg`draft, published, or archived`, }, { slug: "created_at", label: msg`Created At`, type: "datetime", description: msg`When the entry was created`, }, { slug: "updated_at", label: msg`Updated At`, type: "datetime", description: msg`When the entry was last modified`, }, { slug: "published_at", label: msg`Published At`, type: "datetime", description: msg`When the entry was published`, }, ]; /** * Content Type editor for creating/editing collections */ export function ContentTypeEditor({ collection, isNew, isSaving, onSave, onAddField, onUpdateField, onDeleteField, onReorderFields, onCreateRelation, onUpdateRelation, onDeleteRelation, isDeletingRelation, deleteRelationError, }: ContentTypeEditorProps) { const { t } = useLingui(); const _navigate = useNavigate(); // Form state const [slug, setSlug] = React.useState(collection?.slug ?? ""); const [label, setLabel] = React.useState(collection?.label ?? ""); const [labelSingular, setLabelSingular] = React.useState(collection?.labelSingular ?? ""); const [description, setDescription] = React.useState(collection?.description ?? ""); const [urlPattern, setUrlPattern] = React.useState(collection?.urlPattern ?? ""); const [routable, setRoutable] = React.useState(collection?.routable ?? true); const [editLocking, setEditLocking] = React.useState(collection?.editLocking ?? true); const [icon, setIcon] = React.useState(collection?.icon ?? ""); const [group, setGroup] = React.useState(collection?.group ?? ""); const [hidden, setHidden] = React.useState(collection?.hidden ?? false); const [quickCreate, setQuickCreate] = React.useState(collection?.admin?.quickCreate ?? true); // SEO is managed via the separate `hasSeo` field; strip any legacy "seo" entry // so it isn't sent back on save (the API enum rejects it). const [supports, setSupports] = React.useState( (collection?.supports ?? ["drafts", "revisions"]).filter((s) => s !== "seo"), ); // SEO state const [hasSeo, setHasSeo] = React.useState(collection?.hasSeo ?? false); // Comment settings state const [commentsEnabled, setCommentsEnabled] = React.useState( collection?.commentsEnabled ?? false, ); const [commentsModeration, setCommentsModeration] = React.useState<"all" | "first_time" | "none">( collection?.commentsModeration ?? "first_time", ); const [commentsClosedAfterDays, setCommentsClosedAfterDays] = React.useState( collection?.commentsClosedAfterDays ?? 90, ); const [commentsAutoApproveUsers, setCommentsAutoApproveUsers] = React.useState( collection?.commentsAutoApproveUsers ?? true, ); // Field editor state const [fieldEditorOpen, setFieldEditorOpen] = React.useState(false); const [editingField, setEditingField] = React.useState(); const [fieldSaving, setFieldSaving] = React.useState(false); const [fieldSaveError, setFieldSaveError] = React.useState(null); const [deleteFieldTarget, setDeleteFieldTarget] = React.useState(null); // Checked by default: deleting a reference field almost always means the // relationship it views is finished too. const [deleteFieldRelation, setDeleteFieldRelation] = React.useState(true); const urlPatternChanged = urlPattern !== (collection?.urlPattern ?? ""); const urlPatternSharesSegment = urlPatternChanged && MULTIPLE_PLACEHOLDERS_IN_SEGMENT.test(urlPattern); const urlPatternValid = !urlPattern || (urlPattern.includes("{slug}") && !urlPatternSharesSegment); const slugError = isNew && !slug && label.trim().length > 0 ? t`A slug cannot be generated from this label. Type one manually using lowercase letters, numbers, and underscores.` : undefined; // Track whether form has unsaved changes const hasChanges = React.useMemo(() => { if (isNew) return slug && label; if (!collection) return false; return ( label !== collection.label || labelSingular !== (collection.labelSingular ?? "") || description !== (collection.description ?? "") || urlPattern !== (collection.urlPattern ?? "") || routable !== (collection.routable ?? true) || editLocking !== (collection.editLocking ?? true) || icon !== (collection.icon ?? "") || group !== (collection.group ?? "") || hidden !== (collection.hidden ?? false) || quickCreate !== (collection.admin?.quickCreate ?? true) || JSON.stringify([...supports].toSorted()) !== JSON.stringify(collection.supports.filter((s) => s !== "seo").toSorted()) || hasSeo !== collection.hasSeo || commentsEnabled !== collection.commentsEnabled || commentsModeration !== collection.commentsModeration || commentsClosedAfterDays !== collection.commentsClosedAfterDays || commentsAutoApproveUsers !== collection.commentsAutoApproveUsers ); }, [ isNew, collection, slug, label, labelSingular, description, urlPattern, routable, editLocking, icon, group, hidden, quickCreate, supports, hasSeo, commentsEnabled, commentsModeration, commentsClosedAfterDays, commentsAutoApproveUsers, ]); // Auto-generate slug from plural label const handleLabelChange = (value: string) => { setLabel(value); if (isNew) { setSlug(slugifyIdentifier(value)); } }; // Auto-generate plural label (and slug) from singular label const handleSingularLabelChange = (value: string) => { setLabelSingular(value); if (isNew) { const pluralLabel = value ? `${value}s` : ""; handleLabelChange(pluralLabel); } }; const handleSupportToggle = (value: string) => { setSupports((prev) => prev.includes(value) ? prev.filter((s) => s !== value) : [...prev, value], ); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (isNew) { onSave({ slug, label, labelSingular: labelSingular || undefined, description: description || undefined, urlPattern: urlPattern || undefined, routable, editLocking, icon: icon.trim() || undefined, group: group.trim() || undefined, hidden, admin: quickCreate ? undefined : { quickCreate: false }, supports, hasSeo, }); } else { onSave({ label, labelSingular: labelSingular || undefined, description: description || undefined, urlPattern: urlPattern || undefined, routable, editLocking, icon: icon.trim(), group: group.trim() || null, hidden, admin: quickCreate !== (collection?.admin?.quickCreate ?? true) ? { ...collection?.admin, quickCreate: quickCreate ? undefined : false } : undefined, supports, hasSeo, commentsEnabled, commentsModeration, commentsClosedAfterDays, commentsAutoApproveUsers, }); } }; const handleFieldSave = async (input: CreateFieldInput) => { setFieldSaving(true); setFieldSaveError(null); try { if (editingField) { await onUpdateField?.(editingField.slug, input); } else { await onAddField?.(input); } setFieldEditorOpen(false); setEditingField(undefined); } catch (err) { setFieldSaveError(getMutationError(err)); } finally { setFieldSaving(false); } }; const requestDeleteField = (field: SchemaField) => { setDeleteFieldRelation(true); setDeleteFieldTarget(field); }; const handleEditField = (field: SchemaField) => { if (field.unsupportedType) return; setEditingField(field); setFieldSaveError(null); setFieldEditorOpen(true); }; const handleAddField = () => { setEditingField(undefined); setFieldSaveError(null); setFieldEditorOpen(true); }; const isFromCode = collection?.source === "code"; const fields = collection?.fields ?? []; const { data: relations = [], isLoading: relationsLoading } = useQuery({ queryKey: ["relations"], queryFn: () => fetchRelations(), }); const { data: allCollections = [] } = useQuery({ queryKey: ["schema", "collections"], queryFn: fetchCollections, }); const targetRelationSlug = deleteFieldTarget?.validation?.relation; const targetRelation = relations.find((rel) => rel.slug === targetRelationSlug); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = fields.findIndex((f) => f.id === active.id); const newIndex = fields.findIndex((f) => f.id === over.id); if (oldIndex === -1 || newIndex === -1) return; const reordered = arrayMove(fields, oldIndex, newIndex); onReorderFields?.(reordered.map((f) => f.slug)); }; return (
} /> } actions={ !isFromCode && !isNew ? ( ) : null } >

{isNew ? t`New Content Type` : collection?.label}

{!isNew && (

{collection?.slug} {isFromCode && {t`Defined in code`}}

)}
{isFromCode && (

{t`This collection is defined in code. Some settings cannot be changed here. Edit your live.config.ts file to modify the schema.`}

)}
{/* Settings form */}

{t`Settings`}

handleSingularLabelChange(e.target.value)} placeholder={t`Post`} disabled={isFromCode} /> handleLabelChange(e.target.value)} placeholder={t`Posts`} disabled={isFromCode} /> {isNew && (
setSlug(e.target.value)} placeholder="posts" disabled={!isNew} error={slugError} />

{t`Used in URLs and API endpoints`}

)} setDescription(e.target.value)} placeholder={t`A brief description of this content type`} rows={3} disabled={isFromCode} /> {t`Routable`}

{t`Require a slug before content can be published`}

} /> {t`Edit locking`}

{t`Hold an entry while someone is editing it, and refuse other writers`}

} />
setUrlPattern(e.target.value)} placeholder={`/${slug === "pages" ? "" : `${slug}/`}{slug}`} disabled={isFromCode} /> {urlPattern && !urlPattern.includes("{slug}") && (

{t`Pattern must include a ${"{slug}"} placeholder`}

)} {urlPatternSharesSegment && (

{t`Each path segment can contain at most one placeholder, e.g. ${"/{year}/{slug}"}`}

)}

{t`Pattern for generating URLs, e.g. /blog/${"{slug}"}. Tokens: ${"{slug}"}, ${"{id}"}, and date tokens ${"{year}"}/${"{month}"}/${"{day}"} (also ${"{hour}"}/${"{minute}"}/${"{second}"}) from the publish date — e.g. ${"/{year}/{month}/{day}/{slug}.html"} for WordPress-style permalinks.`}

setGroup(e.target.value)} placeholder={t`Calendar`} disabled={isFromCode} />

{t`Content types with the same group share a collapsible folder in the sidebar`}

setIcon(e.target.value)} disabled={isFromCode} />

{t`Phosphor icon name, e.g. calendar-blank or trophy`}

{t`Hide from navigation`}

{t`Removes the sidebar entry, command palette link, and dashboard quick action; entries stay reachable by URL, API, and plugins`}

} /> {t`Quick action on the dashboard`}

{t`Shows a button for creating a new entry`}

} />
{SUPPORT_OPTIONS.map((option) => (
handleSupportToggle(option.value)} disabled={isFromCode} label={
{t(option.label)}

{t(option.description)}

} />
))}
{/* SEO toggle */}
setHasSeo(checked)} disabled={isFromCode} label={
{t`SEO`}

{t`Add SEO metadata fields (title, description, image) and include in sitemap`}

} />
{/* Comments settings — only for existing collections */} {!isNew && (

{t`Comments`}

setCommentsEnabled(checked)} disabled={isFromCode} label={
{t`Enable comments`}

{t`Allow visitors to leave comments on this collection's content`}

} /> {commentsEnabled && ( <> { const parsed = Number.parseInt(e.target.value, 10); setCommentsClosedAfterDays(Number.isNaN(parsed) ? 0 : Math.max(0, parsed)); }} disabled={isFromCode} />

{t`Set to 0 to never close comments automatically.`}

setCommentsAutoApproveUsers(checked)} disabled={isFromCode} label={
{t`Auto-approve authenticated users`}

{t`Comments from logged-in CMS users are approved automatically`}

} /> )}
)} {!isFromCode && (isNew ? ( ) : ( ))} {/* Fields and relations - only shown for existing collections */} {!isNew && (

{t`Fields`}

{SYSTEM_FIELDS.length} system + {fields.length} custom{" "} {plural(fields.length, { one: "field", other: "fields" })}

{!isFromCode && ( )}
{/* System fields - always shown */}
{t`System Fields`}
{SYSTEM_FIELDS.map((field) => ( ))}
{/* Custom fields */} {fields.length === 0 ? (

{t`No custom fields yet`}

{t`Add fields to define the structure of your content`}

{!isFromCode && ( )}
) : ( <>
{t`Custom Fields`}
f.id)} strategy={verticalListSortingStrategy} >
{fields.map((field) => ( handleEditField(field)} onDelete={() => requestDeleteField(field)} /> ))}
)}
{collection && ( )}
)} {/* Field editor dialog */} { setFieldEditorOpen(nextOpen); if (!nextOpen) setFieldSaveError(null); }} field={editingField} onSave={handleFieldSave} isSaving={fieldSaving} saveError={fieldSaveError} collectionSlug={collection?.slug} onCreateRelation={onCreateRelation} /> setDeleteFieldTarget(null)} title={t`Delete Field?`} description={ deleteFieldTarget ? t`Are you sure you want to delete the "${deleteFieldTarget.label}" field?` : "" } confirmLabel={t`Delete`} pendingLabel={t`Deleting...`} isPending={false} error={null} onConfirm={() => { if (deleteFieldTarget) { onDeleteField?.( deleteFieldTarget.slug, targetRelation ? { deleteRelation: deleteFieldRelation } : undefined, ); setDeleteFieldTarget(null); } }} > {targetRelation && (
setDeleteFieldRelation(checked === true)} label={t`Also delete the relationship this field uses`} /> {deleteFieldRelation && collection && (

{t`This also removes:`}

)}
)}
); } interface FieldRowProps { field: SchemaField; isFromCode?: boolean; onEdit: () => void; onDelete: () => void; } function FieldRow({ field, isFromCode, onEdit, onDelete }: FieldRowProps) { const { t } = useLingui(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.id, disabled: isFromCode, }); const style = { transform: CSS.Transform.toString(transform), transition }; return (
{!isFromCode && ( )}
{field.label} {field.slug}
{field.unsupportedType?.type ?? field.type} {field.unsupportedType && {t`Unsupported`}} {field.required && {t`Required`}} {field.unique && {t`Unique`}} {field.searchable && {t`Searchable`}}
{!isFromCode && (
)}
); } interface SystemFieldInfo { slug: string; label: MessageDescriptor; type: string; description: MessageDescriptor; } function SystemFieldRow({ field }: { field: SystemFieldInfo }) { const { t } = useLingui(); return (
{/* Spacer for alignment with draggable fields */}
{t(field.label)} {field.slug} {t`System`}

{t(field.description)}

); } #: packages/admin/src/components/ContentTypeEditor.tsx:754 msgid "{0} system + {1} custom {2}" msgstr "{0} sistem + {1} kustom {2}" @@ -553,7 +553,7 @@ msgstr "{hidden, plural, other {+# lainnya}}" #: packages/admin/src/components/settings/ChangeDomainDialog.tsx:38 msgid "{host} does not resolve to a public address" -msgstr "" +msgstr "{host} tidak menyelesaikan ke alamat publik" #: packages/admin/src/components/MediaImportSummary.tsx:9 msgid "{importedFiles, plural, one {# file imported} other {# files imported}}" @@ -668,15 +668,15 @@ msgstr "{needsNewFields, plural, other {Field akan ditambahkan ke # koleksi yang #: packages/admin/src/components/settings/ChangeDomainDialog.tsx:49 msgid "{origin} does not serve this site yet" -msgstr "" +msgstr "{origin} belum menyajikan situs ini" #: packages/admin/src/components/settings/ChangeDomainDialog.tsx:47 msgid "{origin} redirects elsewhere" -msgstr "" +msgstr "{origin} mengalihkan ke tujuan lain" #: packages/admin/src/components/settings/ChangeDomainDialog.tsx:46 msgid "{origin} redirects to {target}. Enter that address instead." -msgstr "" +msgstr "{origin} mengalihkan ke {target}. Masukkan alamat itu sebagai gantinya." #: packages/admin/src/components/Dashboard.tsx:151 msgid "{overdueCount, plural, one {One scheduled item is overdue and the scheduler heartbeat is stale. Run `npx emdash doctor` and verify the deployed Cron Trigger.} other {# scheduled items are overdue and the scheduler heartbeat is stale. Run `npx emdash doctor` and verify the deployed Cron Trigger.}}" @@ -1083,7 +1083,11 @@ msgstr "Slug tidak dapat dihasilkan dari label ini. Ketik secara manual mengguna #: packages/admin/src/components/FieldEditor.tsx:1242 msgid "A slug cannot be generated from this label. Type one manually." -msgstr "" +<<<<<<< HEAD +msgstr "Slug tidak dapat dihasilkan dari label ini. Ketik yang baru secara manual." +======= +msgstr "Slug tidak dapat dihasilkan dari label ini. Ketik secara manual." +>>>>>>> 1826f20 (i18n(id): add changeset and tighten slug generation message) #: packages/admin/src/components/TaxonomySidebar.tsx:762 msgid "A source and target locale are required" @@ -1360,7 +1364,6 @@ msgstr "Tambahkan tag meta noindex" msgid "Add Passkey" msgstr "Tambahkan Kunci Sandi" -#: packages/admin/src/components/ContentEditor.tsx:2840 #: packages/admin/src/components/ContentEditor.tsx:2840 msgid "Add reference" msgstr "Tambahkan referensi" @@ -1706,24 +1709,15 @@ msgstr "Kunci digital terenkripsi yang Anda buka menggunakan sidik jari, wajah, #: packages/admin/src/components/TaxonomySidebar.tsx:687 #: packages/admin/src/components/TaxonomySidebar.tsx:783 #: packages/admin/src/components/ThemeMarketplaceBrowse.tsx:114 -#: packages/admin/src/router.tsx:581 -#: packages/admin/src/router.tsx:596 -#: packages/admin/src/router.tsx:610 -#: packages/admin/src/router.tsx:624 -#: packages/admin/src/router.tsx:846 -#: packages/admin/src/router.tsx:1253 -#: packages/admin/src/router.tsx:1356 -#: packages/admin/src/router.tsx:1390 -#: packages/admin/src/router.tsx:1414 -#: packages/admin/src/router.tsx:1439 -#: packages/admin/src/router.tsx:1495 -#: packages/admin/src/router.tsx:1514 -#: packages/admin/src/router.tsx:1547 -#: packages/admin/src/router.tsx:1568 -#: packages/admin/src/router.tsx:2317 -#: packages/admin/src/router.tsx:2333 -#: packages/admin/src/router.tsx:2358 -#: packages/admin/src/router.tsx:2960 +#: packages/admin/src/router.tsx:581 packages/admin/src/router.tsx:596 +#: packages/admin/src/router.tsx:610 packages/admin/src/router.tsx:624 +#: packages/admin/src/router.tsx:846 packages/admin/src/router.tsx:1253 +#: packages/admin/src/router.tsx:1356 packages/admin/src/router.tsx:1390 +#: packages/admin/src/router.tsx:1414 packages/admin/src/router.tsx:1439 +#: packages/admin/src/router.tsx:1495 packages/admin/src/router.tsx:1514 +#: packages/admin/src/router.tsx:1547 packages/admin/src/router.tsx:1568 +#: packages/admin/src/router.tsx:2317 packages/admin/src/router.tsx:2333 +#: packages/admin/src/router.tsx:2358 packages/admin/src/router.tsx:2960 #: packages/admin/src/routes/byline-schema.tsx:103 #: packages/admin/src/routes/byline-schema.tsx:123 #: packages/admin/src/routes/byline-schema.tsx:139 @@ -3400,7 +3394,7 @@ msgstr "Tidak dapat memublikasikan" #: packages/admin/src/components/settings/ChangeDomainDialog.tsx:43 msgid "Could not reach {origin}" -msgstr "" +msgstr "Tidak dapat mencapai {origin}" #: packages/admin/src/components/calendar/CalendarEntryPanel.tsx:198 msgid "Could not remove the schedule" @@ -5045,7 +5039,7 @@ msgstr "Inggris digunakan karena tidak ada bahasa konten yang dikonfigurasikan. #: packages/admin/src/components/settings/ChangeDomainDialog.tsx:89 msgid "Enter a domain such as example.com, without a path or port" -msgstr "" +msgstr "Masukkan domain seperti example.com, tanpa jalur atau port" #: packages/admin/src/components/BylineCreditsEditor.tsx:312 msgid "Enter a name." @@ -5487,10 +5481,8 @@ msgstr "Gagal membuat widget" msgid "Failed to create widget area" msgstr "Gagal membuat area widget" -#: packages/admin/src/router.tsx:580 -#: packages/admin/src/router.tsx:609 -#: packages/admin/src/router.tsx:692 -#: packages/admin/src/router.tsx:1567 +#: packages/admin/src/router.tsx:580 packages/admin/src/router.tsx:609 +#: packages/admin/src/router.tsx:692 packages/admin/src/router.tsx:1567 msgid "Failed to delete" msgstr "Gagal menghapus" @@ -5875,8 +5867,7 @@ msgstr "Gagal mengambil status pembaruan" #: packages/admin/src/lib/api/current-user.ts:22 #: packages/admin/src/lib/api/users.ts:91 -#: packages/admin/src/lib/api/users.ts:96 -#: packages/admin/src/router.tsx:1127 +#: packages/admin/src/lib/api/users.ts:96 packages/admin/src/router.tsx:1127 #: packages/admin/src/router.tsx:2263 msgid "Failed to fetch user" msgstr "Gagal mengambil pengguna" @@ -6087,8 +6078,7 @@ msgstr "Gagal menyiapkan impor" msgid "Failed to probe URL" msgstr "Gagal menyelidiki URL" -#: packages/admin/src/router.tsx:648 -#: packages/admin/src/router.tsx:1389 +#: packages/admin/src/router.tsx:648 packages/admin/src/router.tsx:1389 msgid "Failed to publish" msgstr "Gagal memublikasikan" @@ -6173,8 +6163,7 @@ msgstr "Gagal mencabut token API" msgid "Failed to rewrite URLs" msgstr "Gagal menulis ulang URL" -#: packages/admin/src/router.tsx:841 -#: packages/admin/src/router.tsx:1250 +#: packages/admin/src/router.tsx:841 packages/admin/src/router.tsx:1250 #: packages/admin/src/router.tsx:2959 msgid "Failed to save" msgstr "Gagal menyimpan" @@ -7472,7 +7461,7 @@ msgstr "Undang Pengguna" #: packages/admin/src/lib/api/users.ts:194 msgid "Invite validation failed" -msgstr "" +msgstr "Validasi undangan gagal" #: packages/admin/src/components/users/UserList.tsx:133 msgid "Invite your first team member" @@ -7909,8 +7898,7 @@ msgstr "Memuat koleksi..." msgid "Loading comments..." msgstr "Memuat komentar..." -#: packages/admin/src/router.tsx:3212 -#: packages/admin/src/router.tsx:3224 +#: packages/admin/src/router.tsx:3212 packages/admin/src/router.tsx:3224 msgid "Loading configuration..." msgstr "Memuat konfigurasi..." @@ -10028,8 +10016,7 @@ msgstr "Registri Pengaya" msgid "Plugin registry configuration error" msgstr "Galat konfigurasi registri pengaya" -#: packages/admin/src/router.tsx:2555 -#: packages/admin/src/router.tsx:2586 +#: packages/admin/src/router.tsx:2555 packages/admin/src/router.tsx:2586 msgid "Plugin registry is not configured." msgstr "Registri pengaya tidak dikonfigurasikan." @@ -11573,7 +11560,6 @@ msgstr "SCSS" #: packages/admin/src/components/ContentTypeEditor.tsx:106 #: packages/admin/src/components/Settings.tsx:165 -#: packages/admin/src/components/Settings.tsx:165 #: packages/admin/src/components/Widgets.tsx:152 msgid "Search" msgstr "Cari" @@ -12320,7 +12306,7 @@ msgstr "Masuk gagal. Silakan coba lagi." #: packages/admin/src/lib/api/users.ts:431 msgid "Signup completion failed" -msgstr "" +msgstr "Penyelesaian pendaftaran gagal" #: packages/admin/src/lib/api/users.ts:377 msgid "Signup request failed" @@ -13844,7 +13830,7 @@ msgstr "Nama Token" #: packages/admin/src/lib/api/users.ts:393 msgid "Token verification failed" -msgstr "" +msgstr "Verifikasi token gagal" #: packages/admin/src/components/editor/codeBlockLanguages.ts:55 msgid "TOML"