Implement typed template library and rendering
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@govoplan/templates-webui",
|
||||
"version": "0.1.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/templates.css": "./src/styles/templates.css"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
apiDownload,
|
||||
apiFetch,
|
||||
apiPath,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
export type TemplateType =
|
||||
| "label"
|
||||
| "label_sheet"
|
||||
| "envelope"
|
||||
| "serial_letter"
|
||||
| "form_letter"
|
||||
| "list_layout"
|
||||
| "email"
|
||||
| "generic";
|
||||
|
||||
export type TemplateFieldType =
|
||||
| "string"
|
||||
| "integer"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "datetime"
|
||||
| "object"
|
||||
| "array";
|
||||
|
||||
export type TemplateFieldRequirement = {
|
||||
path: string;
|
||||
value_type: TemplateFieldType;
|
||||
label?: string | null;
|
||||
required: boolean;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type TemplateOutputProfile = {
|
||||
id: string;
|
||||
label: string;
|
||||
output_format: "html" | "text";
|
||||
media_type: string;
|
||||
channel: string;
|
||||
capabilities: string[];
|
||||
page: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TemplateRevision = {
|
||||
id: string;
|
||||
revision: number;
|
||||
definition_hash: string;
|
||||
template_type: TemplateType;
|
||||
usages: string[];
|
||||
locale: string;
|
||||
required_fields: TemplateFieldRequirement[];
|
||||
output_profiles: TemplateOutputProfile[];
|
||||
content_text?: string | null;
|
||||
content_html?: string | null;
|
||||
layout: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
created_by_account_id?: string | null;
|
||||
published_at?: string | null;
|
||||
published_by_account_id?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TemplateDefinition = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
scope_type: "tenant" | "group" | "user";
|
||||
scope_id?: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
template_type: TemplateType;
|
||||
status: string;
|
||||
current_revision: number;
|
||||
resource_revision: number;
|
||||
strong_etag: string;
|
||||
current_revision_id: string;
|
||||
published_revision_id?: string | null;
|
||||
read_only: boolean;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
revision: TemplateRevision;
|
||||
};
|
||||
|
||||
export type TemplatePayload = {
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
scope_type: "tenant" | "group" | "user";
|
||||
scope_id?: string | null;
|
||||
template_type: TemplateType;
|
||||
usages: string[];
|
||||
locale: string;
|
||||
required_fields: TemplateFieldRequirement[];
|
||||
output_profiles: TemplateOutputProfile[];
|
||||
content_text?: string | null;
|
||||
content_html?: string | null;
|
||||
layout: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TemplateCompatibility = {
|
||||
compatible: boolean;
|
||||
template_id: string;
|
||||
revision_id: string;
|
||||
usage?: string | null;
|
||||
output_format?: string | null;
|
||||
missing_fields: string[];
|
||||
incompatible_fields: string[];
|
||||
diagnostics: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type TemplateArtifact = {
|
||||
kind: "managed_file" | "bounded_download";
|
||||
filename: string;
|
||||
content_type: string;
|
||||
size_bytes: number;
|
||||
sha256: string;
|
||||
file_asset_id?: string | null;
|
||||
file_version_id?: string | null;
|
||||
download_path?: string | null;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TemplateRender = {
|
||||
render_id: string;
|
||||
template_id: string;
|
||||
revision_id: string;
|
||||
revision: number;
|
||||
template_hash: string;
|
||||
input_hash: string;
|
||||
renderer_version: string;
|
||||
output_format: "html" | "text";
|
||||
content_type: string;
|
||||
filename: string;
|
||||
item_count: number;
|
||||
page_count: number;
|
||||
output_sha256: string;
|
||||
output_size_bytes: number;
|
||||
diagnostics: Array<Record<string, unknown>>;
|
||||
artifact?: TemplateArtifact | null;
|
||||
generated_at?: string | null;
|
||||
};
|
||||
|
||||
export async function listTemplates(settings: ApiSettings): Promise<TemplateDefinition[]> {
|
||||
const result = await apiFetch<{ items: TemplateDefinition[] }>(
|
||||
settings,
|
||||
apiPath("/api/v1/templates", { limit: 500 })
|
||||
);
|
||||
return result.items;
|
||||
}
|
||||
|
||||
export function listTemplateRevisions(
|
||||
settings: ApiSettings,
|
||||
templateId: string
|
||||
): Promise<TemplateRevision[]> {
|
||||
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(templateId)}/revisions`);
|
||||
}
|
||||
|
||||
export async function listTemplateRenders(
|
||||
settings: ApiSettings,
|
||||
templateId: string
|
||||
): Promise<TemplateRender[]> {
|
||||
const result = await apiFetch<{ items: TemplateRender[] }>(
|
||||
settings,
|
||||
apiPath("/api/v1/templates/renders/history", { template_id: templateId, limit: 100 })
|
||||
);
|
||||
return result.items;
|
||||
}
|
||||
|
||||
export function createTemplate(settings: ApiSettings, payload: TemplatePayload): Promise<TemplateDefinition> {
|
||||
return apiFetch(settings, "/api/v1/templates", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTemplate(
|
||||
settings: ApiSettings,
|
||||
item: TemplateDefinition,
|
||||
payload: TemplatePayload
|
||||
): Promise<TemplateDefinition> {
|
||||
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "If-Match": item.strong_etag },
|
||||
body: JSON.stringify({ ...payload, base_revision: item.resource_revision })
|
||||
});
|
||||
}
|
||||
|
||||
export function publishTemplate(settings: ApiSettings, item: TemplateDefinition): Promise<TemplateDefinition> {
|
||||
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}/publish`, {
|
||||
method: "POST",
|
||||
headers: { "If-Match": item.strong_etag },
|
||||
body: JSON.stringify({ revision: item.current_revision, base_revision: item.resource_revision })
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTemplate(settings: ApiSettings, item: TemplateDefinition): Promise<void> {
|
||||
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}`, {
|
||||
method: "DELETE",
|
||||
headers: { "If-Match": item.strong_etag },
|
||||
body: JSON.stringify({ base_revision: item.resource_revision })
|
||||
});
|
||||
}
|
||||
|
||||
export function checkTemplateCompatibility(
|
||||
settings: ApiSettings,
|
||||
item: TemplateDefinition,
|
||||
usage: string,
|
||||
availableFields: Record<string, string>,
|
||||
outputFormat: "html" | "text"
|
||||
): Promise<TemplateCompatibility> {
|
||||
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}/compatibility`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
revision: item.current_revision,
|
||||
usage: usage || null,
|
||||
output_format: outputFormat,
|
||||
available_fields: availableFields
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function renderTemplate(
|
||||
settings: ApiSettings,
|
||||
item: TemplateDefinition,
|
||||
options: {
|
||||
usage: string;
|
||||
outputFormat: "html" | "text";
|
||||
items: Array<Record<string, unknown>>;
|
||||
final: boolean;
|
||||
persistToFiles: boolean;
|
||||
}
|
||||
): Promise<TemplateRender> {
|
||||
return apiFetch(settings, `/api/v1/templates/${encodeURIComponent(item.id)}/render`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
revision: item.current_revision,
|
||||
usage: options.usage || null,
|
||||
output_format: options.outputFormat,
|
||||
items: options.items,
|
||||
input_snapshot: {
|
||||
source: "templates.webui",
|
||||
supplied_item_count: options.items.length
|
||||
},
|
||||
mode: options.final ? "final" : "preview",
|
||||
idempotency_key: options.final ? `templates-ui:${crypto.randomUUID()}` : null,
|
||||
persist_to_files: options.persistToFiles
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadTemplateRender(settings: ApiSettings, render: TemplateRender): Promise<void> {
|
||||
if (render.artifact?.kind === "bounded_download") {
|
||||
return apiDownload(
|
||||
settings,
|
||||
`/api/v1/templates/renders/${encodeURIComponent(render.render_id)}/download`,
|
||||
render.filename
|
||||
);
|
||||
}
|
||||
const path = render.artifact?.download_path;
|
||||
if (!path) return Promise.reject(new Error("This render has no downloadable artifact."));
|
||||
return apiDownload(settings, path, render.filename);
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
FileCheck2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Send,
|
||||
Trash2,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ApiError,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
IconButton,
|
||||
LoadingFrame,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
ToggleSwitch,
|
||||
formatDateTime,
|
||||
hasScope,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
} from "@govoplan/core-webui";
|
||||
import { WysiwygEditor } from "@govoplan/core-webui/wysiwyg";
|
||||
import {
|
||||
checkTemplateCompatibility,
|
||||
createTemplate,
|
||||
deleteTemplate,
|
||||
downloadTemplateRender,
|
||||
listTemplateRenders,
|
||||
listTemplateRevisions,
|
||||
listTemplates,
|
||||
publishTemplate,
|
||||
renderTemplate,
|
||||
updateTemplate,
|
||||
type TemplateCompatibility,
|
||||
type TemplateDefinition,
|
||||
type TemplateFieldRequirement,
|
||||
type TemplateFieldType,
|
||||
type TemplatePayload,
|
||||
type TemplateRender,
|
||||
type TemplateRevision,
|
||||
type TemplateType
|
||||
} from "../../api/templates";
|
||||
|
||||
type Props = { settings: ApiSettings; auth: AuthInfo };
|
||||
type WorkspaceView = "definition" | "preview";
|
||||
|
||||
const TEMPLATE_TYPES: Array<{ value: TemplateType; label: string }> = [
|
||||
{ value: "label", label: "Label" },
|
||||
{ value: "label_sheet", label: "Label sheet" },
|
||||
{ value: "envelope", label: "Envelope" },
|
||||
{ value: "serial_letter", label: "Serial letter" },
|
||||
{ value: "form_letter", label: "Form letter" },
|
||||
{ value: "list_layout", label: "List layout" },
|
||||
{ value: "email", label: "Email" },
|
||||
{ value: "generic", label: "Generic" }
|
||||
];
|
||||
|
||||
const FIELD_TYPES: TemplateFieldType[] = [
|
||||
"string", "integer", "number", "boolean", "date", "datetime", "object", "array"
|
||||
];
|
||||
|
||||
export default function TemplatesPage({ settings, auth }: Props) {
|
||||
const [items, setItems] = useState<TemplateDefinition[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [draft, setDraft] = useState<TemplatePayload>(emptyPayload());
|
||||
const [savedKey, setSavedKey] = useState("");
|
||||
const [view, setView] = useState<WorkspaceView>("definition");
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [createType, setCreateType] = useState<TemplateType>("form_letter");
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [sampleText, setSampleText] = useState('{\n "name": "Ada Example",\n "address": "Main Street 1",\n "postal_code": "10115",\n "city": "Berlin"\n}');
|
||||
const [usage, setUsage] = useState("campaign.postal");
|
||||
const [outputFormat, setOutputFormat] = useState<"html" | "text">("html");
|
||||
const [persistToFiles, setPersistToFiles] = useState(false);
|
||||
const [compatibility, setCompatibility] = useState<TemplateCompatibility | null>(null);
|
||||
const [render, setRender] = useState<TemplateRender | null>(null);
|
||||
const [revisions, setRevisions] = useState<TemplateRevision[]>([]);
|
||||
const [renders, setRenders] = useState<TemplateRender[]>([]);
|
||||
|
||||
const selected = items.find((item) => item.id === selectedId) ?? null;
|
||||
const canWrite = hasScope(auth, "templates:template:write") || hasScope(auth, "templates:template:admin");
|
||||
const canPublish = hasScope(auth, "templates:template:publish") || hasScope(auth, "templates:template:admin");
|
||||
const canRender = hasScope(auth, "templates:template:render") || hasScope(auth, "templates:template:admin");
|
||||
const dirty = Boolean(selected && draftKey(draft) !== savedKey);
|
||||
const readOnly = !canWrite || Boolean(selected?.read_only);
|
||||
|
||||
const applyItem = useCallback((item: TemplateDefinition | null) => {
|
||||
const next = item ? payloadFromItem(item) : emptyPayload();
|
||||
setDraft(next);
|
||||
setSavedKey(item ? draftKey(next) : "");
|
||||
setCompatibility(null);
|
||||
setRender(null);
|
||||
setUsage(item?.revision.usages[0] ?? "campaign.postal");
|
||||
}, []);
|
||||
|
||||
const reload = useCallback(async (preferredId?: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const nextItems = await listTemplates(settings);
|
||||
setItems(nextItems);
|
||||
const nextId = preferredId && nextItems.some((item) => item.id === preferredId)
|
||||
? preferredId
|
||||
: nextItems.some((item) => item.id === selectedId)
|
||||
? selectedId
|
||||
: nextItems[0]?.id ?? "";
|
||||
setSelectedId(nextId);
|
||||
applyItem(nextItems.find((item) => item.id === nextId) ?? null);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [applyItem, selectedId, settings]);
|
||||
|
||||
useEffect(() => { void reload(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setRevisions([]);
|
||||
setRenders([]);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
void Promise.all([
|
||||
listTemplateRevisions(settings, selectedId),
|
||||
listTemplateRenders(settings, selectedId)
|
||||
]).then(([nextRevisions, nextRenders]) => {
|
||||
if (!active) return;
|
||||
setRevisions(nextRevisions);
|
||||
setRenders(nextRenders);
|
||||
}).catch((caught) => {
|
||||
if (active) setError(errorMessage(caught));
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [selectedId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const needle = search.trim().toLocaleLowerCase();
|
||||
return needle
|
||||
? items.filter((item) => `${item.name} ${item.template_type} ${item.revision.usages.join(" ")}`.toLocaleLowerCase().includes(needle))
|
||||
: items;
|
||||
}, [items, search]);
|
||||
|
||||
const save = async () => {
|
||||
if (!selected || !draft.name.trim() || !draft.usages.length) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await updateTemplate(settings, selected, draft);
|
||||
setSuccess(`Saved immutable revision ${updated.current_revision}.`);
|
||||
await reload(updated.id);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: () => applyItem(selected) });
|
||||
|
||||
const create = async () => {
|
||||
if (!createName.trim()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createTemplate(settings, {
|
||||
...emptyPayload(createType),
|
||||
name: createName.trim()
|
||||
});
|
||||
setCreateOpen(false);
|
||||
setCreateName("");
|
||||
setSuccess(`Created ${created.name}.`);
|
||||
await reload(created.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const publish = async () => {
|
||||
if (!selected || dirty) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const updated = await publishTemplate(settings, selected);
|
||||
setSuccess(`Published revision ${updated.current_revision}.`);
|
||||
await reload(updated.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await deleteTemplate(settings, selected);
|
||||
setDeleteOpen(false);
|
||||
setSuccess(`Deleted ${selected.name}.`);
|
||||
await reload();
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runRender = async (final: boolean) => {
|
||||
if (!selected || dirty) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const sample = parseSample(sampleText);
|
||||
const fields = flattenFieldTypes(sample);
|
||||
const nextCompatibility = await checkTemplateCompatibility(settings, selected, usage, fields, outputFormat);
|
||||
setCompatibility(nextCompatibility);
|
||||
if (!nextCompatibility.compatible) return;
|
||||
const nextRender = await renderTemplate(settings, selected, {
|
||||
usage,
|
||||
outputFormat,
|
||||
items: [sample],
|
||||
final,
|
||||
persistToFiles
|
||||
});
|
||||
setRender(nextRender);
|
||||
setRenders(await listTemplateRenders(settings, selected.id));
|
||||
setSuccess(`${final ? "Final" : "Preview"} output rendered with ${nextRender.item_count} item(s).`);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="templates-page">
|
||||
<div className="templates-shell">
|
||||
<aside className="templates-sidebar">
|
||||
<div className="templates-sidebar-toolbar">
|
||||
<strong>Template library</strong>
|
||||
<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} onClick={() => setCreateOpen(true)} />
|
||||
</div>
|
||||
<div className="templates-search"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></div>
|
||||
<div className="templates-list">
|
||||
{visibleItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={item.id === selectedId ? "is-selected" : ""}
|
||||
onClick={() => {
|
||||
if (item.id === selectedId) return;
|
||||
setSelectedId(item.id);
|
||||
applyItem(item);
|
||||
}}
|
||||
>
|
||||
<span><strong>{item.name}</strong><small>{typeLabel(item.template_type)} · revision {item.current_revision}</small></span>
|
||||
<StatusBadge status={item.status} label={item.status} />
|
||||
</button>
|
||||
))}
|
||||
{!visibleItems.length && <p className="templates-empty">No matching templates.</p>}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="templates-workspace">
|
||||
<header className="templates-workspace-toolbar">
|
||||
<span className="templates-current-title">
|
||||
<strong>{selected?.name ?? "Select a template"}</strong>
|
||||
<small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small>
|
||||
</span>
|
||||
<div className="templates-toolbar-actions">
|
||||
<IconButton label="Discard and reload" icon={<RefreshCw size={17} />} onClick={() => void reload(selectedId)} />
|
||||
<Button variant="primary" disabled={!selected || readOnly || !dirty || busy} onClick={() => void save()}><Save size={16} /> Save revision</Button>
|
||||
<Button disabled={!selected || !canPublish || dirty || busy} onClick={() => void publish()}><FileCheck2 size={16} /> Publish</Button>
|
||||
<IconButton label="Delete template" icon={<Trash2 size={17} />} variant="danger" disabled={!selected || readOnly} onClick={() => setDeleteOpen(true)} />
|
||||
<SegmentedControl
|
||||
value={view}
|
||||
onChange={setView}
|
||||
options={[{ id: "definition", label: "Definition" }, { id: "preview", label: "Preview" }]}
|
||||
ariaLabel="Template workspace"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="templates-alerts">
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
|
||||
</div>
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading templates">
|
||||
<div className="templates-content">
|
||||
{!selected ? <p className="templates-empty">Create or select a reusable template.</p> : view === "definition" ? <>
|
||||
<DefinitionEditor draft={draft} disabled={readOnly || busy} auth={auth} onChange={setDraft} />
|
||||
<RevisionHistory revisions={revisions} currentRevisionId={selected.current_revision_id} publishedRevisionId={selected.published_revision_id ?? null} />
|
||||
</> : <>
|
||||
<PreviewPanel
|
||||
item={selected}
|
||||
sampleText={sampleText}
|
||||
usage={usage}
|
||||
outputFormat={outputFormat}
|
||||
persistToFiles={persistToFiles}
|
||||
compatibility={compatibility}
|
||||
render={render}
|
||||
disabled={busy || dirty || !canRender}
|
||||
onSampleText={setSampleText}
|
||||
onUsage={setUsage}
|
||||
onOutputFormat={setOutputFormat}
|
||||
onPersistToFiles={setPersistToFiles}
|
||||
onRender={runRender}
|
||||
onDownload={() => render && void downloadTemplateRender(settings, render).catch((caught) => setError(errorMessage(caught)))}
|
||||
/>
|
||||
<RenderHistory renders={renders} settings={settings} onError={setError} />
|
||||
</>}
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} title="Add template" onClose={() => setCreateOpen(false)} footer={<><Button onClick={() => setCreateOpen(false)}>Cancel</Button><Button variant="primary" disabled={!createName.trim() || busy} onClick={() => void create()}>Create</Button></>}>
|
||||
<div className="templates-dialog-form">
|
||||
<FormField label="Name"><input autoFocus value={createName} onChange={(event) => setCreateName(event.target.value)} /></FormField>
|
||||
<FormField label="Type"><select value={createType} onChange={(event) => setCreateType(event.target.value as TemplateType)}>{TEMPLATE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></FormField>
|
||||
</div>
|
||||
</Dialog>
|
||||
<ConfirmDialog open={deleteOpen} title="Delete template?" message="Existing render evidence remains until module retention removes it. Consumers can no longer select this template." confirmLabel="Delete" tone="danger" busy={busy} onCancel={() => setDeleteOpen(false)} onConfirm={() => void remove()} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }: { revisions: TemplateRevision[]; currentRevisionId: string; publishedRevisionId: string | null }) {
|
||||
return <section className="templates-section templates-history">
|
||||
<div className="templates-section-heading"><strong>Revision history</strong><small>{revisions.length} immutable revision(s)</small></div>
|
||||
<div className="templates-history-list">
|
||||
{revisions.map((revision) => <div key={revision.id}>
|
||||
<span><strong>Revision {revision.revision}</strong><small>{formatDateTime(revision.created_at)} · {shortHash(revision.definition_hash)}</small></span>
|
||||
<span className="templates-history-badges">
|
||||
{revision.id === currentRevisionId && <StatusBadge status="current" label="Current" />}
|
||||
{revision.id === publishedRevisionId && <StatusBadge status="active" label="Published" />}
|
||||
</span>
|
||||
</div>)}
|
||||
{!revisions.length && <p className="templates-inline-empty">No revision evidence is available.</p>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function RenderHistory({ renders, settings, onError }: { renders: TemplateRender[]; settings: ApiSettings; onError: (message: string) => void }) {
|
||||
return <section className="templates-section templates-history">
|
||||
<div className="templates-section-heading"><strong>Output history</strong><small>{renders.length} recent render(s)</small></div>
|
||||
<div className="templates-history-list">
|
||||
{renders.map((item) => <div key={item.render_id}>
|
||||
<span><strong>{item.filename}</strong><small>{item.generated_at ? formatDateTime(item.generated_at) : "Generated"} · {item.item_count} item(s) · {shortHash(item.output_sha256)}</small></span>
|
||||
<Button disabled={!item.artifact?.download_path} onClick={() => void downloadTemplateRender(settings, item).catch((caught) => onError(errorMessage(caught)))}><Download size={15} /> Download</Button>
|
||||
</div>)}
|
||||
{!renders.length && <p className="templates-inline-empty">No output has been rendered for this template.</p>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: TemplatePayload; disabled: boolean; auth: AuthInfo; onChange: (draft: TemplatePayload) => void }) {
|
||||
const update = <K extends keyof TemplatePayload>(key: K, value: TemplatePayload[K]) => onChange({ ...draft, [key]: value });
|
||||
const scopeOptions = [
|
||||
{ value: "tenant:", label: "Tenant" },
|
||||
{ value: `user:${auth.user.account_id}`, label: "Only me" },
|
||||
...auth.groups.map((group) => ({ value: `group:${group.id}`, label: `Group: ${group.name}` }))
|
||||
];
|
||||
const scopeValue = `${draft.scope_type}:${draft.scope_id ?? ""}`;
|
||||
const layout = draft.layout;
|
||||
return (
|
||||
<div className="templates-definition">
|
||||
<div className="templates-definition-fields">
|
||||
<FormField label="Name"><input disabled={disabled} value={draft.name} onChange={(event) => update("name", event.target.value)} /></FormField>
|
||||
<FormField label="Type"><select disabled={disabled} value={draft.template_type} onChange={(event) => update("template_type", event.target.value as TemplateType)}>{TEMPLATE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></FormField>
|
||||
<FormField label="Locale"><input disabled={disabled} value={draft.locale} onChange={(event) => update("locale", event.target.value)} /></FormField>
|
||||
<FormField label="Visibility"><select disabled={disabled} value={scopeValue} onChange={(event) => { const [scopeType, scopeId] = event.target.value.split(":", 2); onChange({ ...draft, scope_type: scopeType as TemplatePayload["scope_type"], scope_id: scopeId || null }); }}>{scopeOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></FormField>
|
||||
<FormField label="Usages" help="Comma-separated capability contexts, for example campaign.postal or addresses.labels."><input disabled={disabled} value={draft.usages.join(", ")} onChange={(event) => update("usages", splitValues(event.target.value))} /></FormField>
|
||||
<FormField label="Description"><input disabled={disabled} value={draft.description ?? ""} onChange={(event) => update("description", event.target.value || null)} /></FormField>
|
||||
</div>
|
||||
|
||||
<section className="templates-section">
|
||||
<div className="templates-section-heading"><strong>Required data contract</strong><Button disabled={disabled} onClick={() => update("required_fields", [...draft.required_fields, emptyField()])}><Plus size={15} /> Add field</Button></div>
|
||||
<div className="templates-fields-table">
|
||||
{draft.required_fields.map((field, index) => (
|
||||
<div className="templates-field-row" key={`${index}:${field.path}`}>
|
||||
<input disabled={disabled} value={field.path} placeholder="recipient.address" aria-label="Field path" onChange={(event) => updateField(draft, index, { path: event.target.value }, onChange)} />
|
||||
<select disabled={disabled} value={field.value_type} aria-label="Field type" onChange={(event) => updateField(draft, index, { value_type: event.target.value as TemplateFieldType }, onChange)}>{FIELD_TYPES.map((value) => <option key={value} value={value}>{value}</option>)}</select>
|
||||
<input disabled={disabled} value={field.label ?? ""} placeholder="Label" aria-label="Field label" onChange={(event) => updateField(draft, index, { label: event.target.value || null }, onChange)} />
|
||||
<ToggleSwitch checked={field.required} label="Required" disabled={disabled} onChange={(checked) => updateField(draft, index, { required: checked }, onChange)} />
|
||||
<IconButton label="Remove field" icon={<X size={16} />} variant="ghost" disabled={disabled} onClick={() => update("required_fields", draft.required_fields.filter((_, fieldIndex) => fieldIndex !== index))} />
|
||||
</div>
|
||||
))}
|
||||
{!draft.required_fields.length && <p className="templates-inline-empty">No required fields. Tokens still resolve from supplied parameters and items.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="templates-section">
|
||||
<div className="templates-section-heading"><strong>Page and media</strong></div>
|
||||
<div className="templates-layout-fields">
|
||||
<FormField label="Page size"><select disabled={disabled} value={String(layout.page_size ?? pageSizeForType(draft.template_type))} onChange={(event) => update("layout", { ...layout, page_size: event.target.value })}>{["A3", "A4", "A5", "Letter", "Legal", "DL"].map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
|
||||
<FormField label="Margin (mm)"><input type="number" min="0" max="60" disabled={disabled} value={Number(layout.margin_mm ?? 15)} onChange={(event) => update("layout", { ...layout, margin_mm: Number(event.target.value) })} /></FormField>
|
||||
{draft.template_type === "label_sheet" && <>
|
||||
<FormField label="Columns"><input type="number" min="1" max="12" disabled={disabled} value={Number(layout.columns ?? 3)} onChange={(event) => update("layout", { ...layout, columns: Number(event.target.value) })} /></FormField>
|
||||
<FormField label="Rows"><input type="number" min="1" max="30" disabled={disabled} value={Number(layout.rows ?? 8)} onChange={(event) => update("layout", { ...layout, rows: Number(event.target.value) })} /></FormField>
|
||||
<FormField label="Gap (mm)"><input type="number" min="0" max="20" disabled={disabled} value={Number(layout.gap_mm ?? 2)} onChange={(event) => update("layout", { ...layout, gap_mm: Number(event.target.value) })} /></FormField>
|
||||
</>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="templates-section templates-body-section">
|
||||
<div className="templates-section-heading"><strong>Template body</strong><small>Use tokens such as {"{{name}}"} or {"{{recipient.address}}"}.</small></div>
|
||||
<WysiwygEditor disabled={disabled} value={draft.content_html ?? ""} onChange={(value) => update("content_html", value || null)} minHeight={300} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, compatibility, render, disabled, onSampleText, onUsage, onOutputFormat, onPersistToFiles, onRender, onDownload }: {
|
||||
item: TemplateDefinition;
|
||||
sampleText: string;
|
||||
usage: string;
|
||||
outputFormat: "html" | "text";
|
||||
persistToFiles: boolean;
|
||||
compatibility: TemplateCompatibility | null;
|
||||
render: TemplateRender | null;
|
||||
disabled: boolean;
|
||||
onSampleText: (value: string) => void;
|
||||
onUsage: (value: string) => void;
|
||||
onOutputFormat: (value: "html" | "text") => void;
|
||||
onPersistToFiles: (value: boolean) => void;
|
||||
onRender: (final: boolean) => void;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="templates-preview">
|
||||
<section className="templates-section">
|
||||
<div className="templates-section-heading"><strong>Validated sample input</strong><small>Preview and final output use the same pinned revision and canonical input.</small></div>
|
||||
<div className="templates-preview-controls">
|
||||
<FormField label="Usage"><select value={usage} onChange={(event) => onUsage(event.target.value)}>{item.revision.usages.map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
|
||||
<FormField label="Output"><SegmentedControl value={outputFormat} onChange={onOutputFormat} options={[{ id: "html", label: "Printable HTML" }, { id: "text", label: "Plain text" }]} ariaLabel="Output format" /></FormField>
|
||||
<ToggleSwitch checked={persistToFiles} label="Store in Files when available" onChange={onPersistToFiles} />
|
||||
</div>
|
||||
<textarea className="templates-sample" value={sampleText} onChange={(event) => onSampleText(event.target.value)} spellCheck={false} aria-label="Sample item JSON" />
|
||||
<div className="templates-preview-actions">
|
||||
<Button disabled={disabled} onClick={() => onRender(false)}><Eye size={16} /> Validate and preview</Button>
|
||||
<Button variant="primary" disabled={disabled || !item.revision.published_at} disabledReason={!item.revision.published_at ? "Publish this revision before producing final output." : undefined} onClick={() => onRender(true)}><Send size={16} /> Render final output</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{compatibility && <DismissibleAlert tone={compatibility.compatible ? "success" : "danger"}>
|
||||
{compatibility.compatible
|
||||
? "The selected usage, output profile, and supplied fields are compatible."
|
||||
: compatibility.diagnostics.map((item) => String(item.message ?? item.code ?? "Incompatible input")).join(" ")}
|
||||
</DismissibleAlert>}
|
||||
|
||||
{render && <section className="templates-section templates-render-result">
|
||||
<div className="templates-section-heading"><strong>Render evidence</strong><Button disabled={!render.artifact?.download_path} onClick={onDownload}><Download size={16} /> Download</Button></div>
|
||||
<dl>
|
||||
<div><dt>Revision</dt><dd>{render.revision} · {shortHash(render.template_hash)}</dd></div>
|
||||
<div><dt>Input</dt><dd>{shortHash(render.input_hash)}</dd></div>
|
||||
<div><dt>Output</dt><dd>{shortHash(render.output_sha256)}</dd></div>
|
||||
<div><dt>Renderer</dt><dd>{render.renderer_version}</dd></div>
|
||||
<div><dt>Items / pages</dt><dd>{render.item_count} / {render.page_count}</dd></div>
|
||||
<div><dt>Generated</dt><dd>{render.generated_at ? formatDateTime(render.generated_at) : "Now"}</dd></div>
|
||||
</dl>
|
||||
<p>{render.artifact?.kind === "managed_file" ? "Managed by Files" : "Bounded Templates download"} · {render.output_size_bytes.toLocaleString()} bytes</p>
|
||||
</section>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyPayload(templateType: TemplateType = "form_letter"): TemplatePayload {
|
||||
return {
|
||||
name: "",
|
||||
description: null,
|
||||
scope_type: "tenant",
|
||||
scope_id: null,
|
||||
template_type: templateType,
|
||||
usages: [templateType === "email" ? "campaign.email" : "campaign.postal"],
|
||||
locale: "en",
|
||||
required_fields: [],
|
||||
output_profiles: [],
|
||||
content_text: null,
|
||||
content_html: "<p>Hello {{name}},</p><p></p>",
|
||||
layout: { page_size: pageSizeForType(templateType), margin_mm: 15 },
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
|
||||
function payloadFromItem(item: TemplateDefinition): TemplatePayload {
|
||||
return {
|
||||
name: item.name,
|
||||
slug: item.slug,
|
||||
description: item.description ?? null,
|
||||
scope_type: item.scope_type,
|
||||
scope_id: item.scope_id ?? null,
|
||||
template_type: item.template_type,
|
||||
usages: [...item.revision.usages],
|
||||
locale: item.revision.locale,
|
||||
required_fields: item.revision.required_fields.map((field) => ({ ...field })),
|
||||
output_profiles: item.revision.output_profiles.map((profile) => ({ ...profile, capabilities: [...profile.capabilities], page: { ...profile.page } })),
|
||||
content_text: item.revision.content_text ?? null,
|
||||
content_html: item.revision.content_html ?? null,
|
||||
layout: { ...item.revision.layout },
|
||||
metadata: { ...item.revision.metadata }
|
||||
};
|
||||
}
|
||||
|
||||
function emptyField(): TemplateFieldRequirement {
|
||||
return { path: "", value_type: "string", label: null, required: true, description: null };
|
||||
}
|
||||
|
||||
function updateField(draft: TemplatePayload, index: number, patch: Partial<TemplateFieldRequirement>, onChange: (draft: TemplatePayload) => void) {
|
||||
onChange({ ...draft, required_fields: draft.required_fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field) });
|
||||
}
|
||||
|
||||
function pageSizeForType(type: TemplateType): string { return type === "envelope" ? "DL" : "A4"; }
|
||||
function typeLabel(type: TemplateType): string { return TEMPLATE_TYPES.find((item) => item.value === type)?.label ?? type; }
|
||||
function splitValues(value: string): string[] { return [...new Set(value.split(",").map((item) => item.trim().toLocaleLowerCase()).filter(Boolean))]; }
|
||||
function draftKey(value: TemplatePayload): string { return JSON.stringify(value); }
|
||||
function shortHash(value: string): string { return value.slice(0, 12); }
|
||||
|
||||
function parseSample(value: string): Record<string, unknown> {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error("Sample input must be one JSON object.");
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function flattenFieldTypes(value: Record<string, unknown>, prefix = "", result: Record<string, string> = {}): Record<string, string> {
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (Array.isArray(item)) result[path] = "array";
|
||||
else if (item !== null && typeof item === "object") {
|
||||
result[path] = "object";
|
||||
flattenFieldTypes(item as Record<string, unknown>, path, result);
|
||||
} else if (typeof item === "number") result[path] = Number.isInteger(item) ? "integer" : "number";
|
||||
else result[path] = typeof item;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
try {
|
||||
const parsed = JSON.parse(error.body) as { detail?: unknown };
|
||||
return typeof parsed.detail === "string" ? parsed.detail : JSON.stringify(parsed.detail ?? parsed);
|
||||
} catch { return error.message; }
|
||||
}
|
||||
return error instanceof Error ? error.message : "The template operation failed.";
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default } from "./module";
|
||||
export * from "./module";
|
||||
export * from "./api/templates";
|
||||
export { default as TemplatesPage } from "./features/templates/TemplatesPage";
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import "./styles/templates.css";
|
||||
|
||||
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
|
||||
|
||||
const readScopes = [
|
||||
"templates:template:read",
|
||||
"templates:template:write",
|
||||
"templates:template:publish",
|
||||
"templates:template:render",
|
||||
"templates:template:admin"
|
||||
];
|
||||
|
||||
export const templatesModule: PlatformWebModule = {
|
||||
id: "templates",
|
||||
label: "Templates",
|
||||
version: "0.1.14",
|
||||
optionalDependencies: ["files", "dist_lists", "campaigns", "audit"],
|
||||
navItems: [{
|
||||
to: "/templates",
|
||||
label: "Templates",
|
||||
iconName: "layout-template",
|
||||
anyOf: readScopes,
|
||||
order: 75
|
||||
}],
|
||||
routes: [{
|
||||
path: "/templates",
|
||||
anyOf: readScopes,
|
||||
order: 75,
|
||||
render: ({ settings, auth }) => createElement(TemplatesPage, { settings, auth })
|
||||
}]
|
||||
};
|
||||
|
||||
export default templatesModule;
|
||||
@@ -0,0 +1,114 @@
|
||||
.templates-page {
|
||||
height: calc(100vh - 115px);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.templates-page *, .templates-page *::before, .templates-page *::after { box-sizing: border-box; }
|
||||
|
||||
.templates-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: var(--border-line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.templates-sidebar, .templates-workspace { min-width: 0; min-height: 0; }
|
||||
.templates-sidebar { display: flex; flex-direction: column; overflow: hidden; border-right: var(--border-line); background: var(--panel-soft); }
|
||||
.templates-workspace { display: flex; flex-direction: column; overflow: hidden; background: var(--bg); }
|
||||
|
||||
.templates-sidebar-toolbar, .templates-workspace-toolbar, .templates-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
}
|
||||
|
||||
.templates-sidebar-toolbar { min-height: 52px; padding: 8px 10px 8px 14px; }
|
||||
.templates-workspace-toolbar { min-height: 58px; padding: 8px 10px 8px 14px; }
|
||||
.templates-toolbar-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; }
|
||||
.templates-toolbar-actions .btn { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.templates-current-title { min-width: 0; flex: 1 1 auto; }
|
||||
.templates-current-title strong, .templates-current-title small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.templates-current-title small { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||
|
||||
.templates-search { padding: 9px; border-bottom: var(--border-line); background: var(--panel); }
|
||||
.templates-search input { width: 100%; min-height: 34px; padding: 7px 9px; }
|
||||
.templates-list { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 6px; }
|
||||
.templates-list > button { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 56px; padding: 8px 9px; border: 0; border-radius: var(--radius-sm); color: var(--text); background: transparent; cursor: pointer; text-align: left; }
|
||||
.templates-list > button:hover, .templates-list > button:focus-visible { background: var(--primary-soft); outline: 0; }
|
||||
.templates-list > button.is-selected { background: var(--primary-soft-strong); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.templates-list strong, .templates-list small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.templates-list small { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||
|
||||
.templates-alerts { flex: 0 0 auto; padding: 0 12px; }
|
||||
.templates-alerts:empty { display: none; }
|
||||
.templates-alerts .alert { margin: 10px 0 0; }
|
||||
.templates-workspace > .loading-frame { flex: 1 1 auto; min-height: 0; }
|
||||
.templates-content { height: 100%; min-width: 0; min-height: 0; overflow: auto; padding: 14px; }
|
||||
.templates-empty, .templates-inline-empty { display: grid; place-items: center; min-height: 90px; padding: 16px; color: var(--muted); font-size: 13px; text-align: center; }
|
||||
|
||||
.templates-definition-fields { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(130px, .7fr)); gap: 12px; margin-bottom: 14px; }
|
||||
.templates-definition-fields .form-field:nth-child(5) { grid-column: span 2; }
|
||||
.templates-definition-fields input, .templates-definition-fields select, .templates-dialog-form input, .templates-dialog-form select, .templates-layout-fields input, .templates-layout-fields select, .templates-preview-controls select { width: 100%; }
|
||||
|
||||
.templates-section { min-width: 0; margin-bottom: 14px; border: var(--border-line); background: var(--panel); }
|
||||
.templates-section-heading { min-height: 44px; padding: 7px 10px; }
|
||||
.templates-section-heading small { color: var(--muted); font-weight: 400; }
|
||||
.templates-section-heading .btn { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.templates-fields-table { overflow: auto; padding: 8px; }
|
||||
.templates-field-row { display: grid; grid-template-columns: minmax(180px, 1.2fr) minmax(110px, .6fr) minmax(160px, 1fr) auto 34px; align-items: center; gap: 8px; min-width: 710px; padding: 4px 0; }
|
||||
.templates-field-row input, .templates-field-row select { width: 100%; }
|
||||
.templates-layout-fields { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); gap: 12px; padding: 12px; }
|
||||
.templates-body-section .wysiwyg-editor { margin: 12px; }
|
||||
|
||||
.templates-preview { max-width: 1100px; margin: 0 auto; }
|
||||
.templates-preview-controls { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(250px, 1.2fr) auto; align-items: end; gap: 14px; padding: 12px; }
|
||||
.templates-sample { display: block; width: calc(100% - 24px); min-height: 260px; margin: 0 12px; padding: 10px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
|
||||
.templates-preview-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px; }
|
||||
.templates-preview-actions .btn, .templates-render-result .btn { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.templates-render-result dl { display: grid; grid-template-columns: repeat(3, minmax(160px, 1fr)); gap: 10px; margin: 0; padding: 12px; }
|
||||
.templates-render-result dl div { padding: 9px; border: var(--border-line); background: var(--panel-soft); }
|
||||
.templates-render-result dt { color: var(--muted); font-size: 11px; text-transform: uppercase; }
|
||||
.templates-render-result dd { margin: 4px 0 0; overflow: hidden; text-overflow: ellipsis; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.templates-render-result > p { margin: 0; padding: 0 12px 12px; color: var(--muted); }
|
||||
.templates-history-list { max-height: 260px; overflow: auto; padding: 6px; }
|
||||
.templates-history-list > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 50px; padding: 7px 9px; border-bottom: var(--border-line); }
|
||||
.templates-history-list > div:last-child { border-bottom: 0; }
|
||||
.templates-history-list strong, .templates-history-list small { display: block; }
|
||||
.templates-history-list small { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||
.templates-history-list .btn { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.templates-history-badges { display: flex; align-items: center; gap: 6px; }
|
||||
.templates-dialog-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(180px, .7fr); gap: 12px; min-width: min(560px, 80vw); }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.templates-shell { grid-template-columns: minmax(210px, 250px) minmax(0, 1fr); }
|
||||
.templates-definition-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.templates-definition-fields .form-field:nth-child(5) { grid-column: auto; }
|
||||
.templates-layout-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.templates-preview-controls { grid-template-columns: 1fr; align-items: stretch; }
|
||||
.templates-render-result dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.templates-page { height: auto; min-height: calc(100vh - 100px); overflow: visible; }
|
||||
.templates-shell { display: flex; flex-direction: column; height: auto; overflow: visible; }
|
||||
.templates-sidebar { max-height: 280px; border-right: 0; border-bottom: var(--border-line); }
|
||||
.templates-workspace { overflow: visible; }
|
||||
.templates-workspace-toolbar { align-items: flex-start; flex-wrap: wrap; }
|
||||
.templates-toolbar-actions { flex-wrap: wrap; }
|
||||
.templates-content { height: auto; overflow: visible; }
|
||||
.templates-definition-fields, .templates-dialog-form, .templates-render-result dl { grid-template-columns: 1fr; }
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
readonly VITE_CSRF_COOKIE_NAME?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare module "virtual:govoplan-installed-modules" {
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
|
||||
const installedWebModuleLoaders: Array<{
|
||||
packageName: string;
|
||||
load: () => Promise<{ default: PlatformWebModule }>;
|
||||
}>;
|
||||
|
||||
export default installedWebModuleLoaders;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"preserveSymlinks": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
|
||||
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
|
||||
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
|
||||
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
|
||||
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user