feat: user-managed templates

This commit is contained in:
2026-02-27 20:00:53 +01:00
parent e25a0d85a5
commit f3364999ee
47 changed files with 3664 additions and 40 deletions

View File

@@ -36,6 +36,12 @@ const ScriptsIcon = () => (
</svg>
);
const TemplatesIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7zM5.5 5.5v4h4v-4h-4zm9 0v4h4v-4h-4zm-9 9v4h4v-4h-4zm9 0v4h4v-4h-4z"/>
</svg>
);
const SettingsIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>
@@ -183,6 +189,13 @@ export const ActivityBar: React.FC = () => {
>
<ScriptsIcon />
</button>
<button
className={`activity-bar-item ${isActivityActive(snapshot, 'templates') ? 'active' : ''}`}
onClick={() => executeActivityClick('templates')}
title={getTitle('templates')}
>
<TemplatesIcon />
</button>
<button
className={`activity-bar-item ${isActivityActive(snapshot, 'tags') ? 'active' : ''}`}
onClick={() => executeActivityClick('tags')}

View File

@@ -20,6 +20,7 @@ import { GitDiffView } from '../GitDiffView/GitDiffView';
import { DocumentationView } from '../DocumentationView/DocumentationView';
import { SiteValidationView } from '../SiteValidationView';
import { ScriptsView } from '../ScriptsView/ScriptsView';
import { TemplatesView } from '../TemplatesView/TemplatesView';
import { AutoSaveManager, getContrastColor, loadTagColorMap } from '../../utils';
import { InsertModal } from '../InsertModal';
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
@@ -71,6 +72,9 @@ const autoSaveManager = new AutoSaveManager({
if ('categories' in changes) {
update.categories = changes.categories as string[];
}
if ('templateSlug' in changes) {
(update as Record<string, unknown>).templateSlug = changes.templateSlug as string || null;
}
const updated = await window.electronAPI?.posts.update(id, update);
if (updated) {
@@ -191,6 +195,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const [author, setAuthor] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']);
const [templateSlug, setTemplateSlug] = useState('');
const [availablePostTemplates, setAvailablePostTemplates] = useState<Array<{ slug: string; title: string }>>([]);
const [isSaving, setIsSaving] = useState(false);
const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
@@ -319,10 +325,15 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
setAuthor(post.author || '');
setTags(post.tags);
setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']);
setTemplateSlug((post as PostData & { templateSlug?: string }).templateSlug || '');
setMetadataExpanded(post.title === '');
markClean(postId);
// Mark as initialized AFTER setting local state
setIsInitialized(true);
// Load available post templates for the dropdown
window.electronAPI?.templates.getEnabledByKind('post').then((templates) => {
setAvailablePostTemplates((templates ?? []).map((tmpl) => ({ slug: tmpl.slug, title: tmpl.title })));
});
}
}, [post, postId, markClean, isInitialized]);
@@ -335,7 +346,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const contentChanged = content !== post.content;
const titleChanged = title !== post.title;
const authorChanged = author !== (post.author || '');
const hasChanges = contentChanged || titleChanged || authorChanged ||
const templateSlugChanged = templateSlug !== ((post as PostData & { templateSlug?: string }).templateSlug || '');
const hasChanges = contentChanged || titleChanged || authorChanged || templateSlugChanged ||
JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort()) ||
JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort());
@@ -349,11 +361,12 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
author,
tags: tags.join(', '),
categories: selectedCategories,
templateSlug: templateSlug || undefined,
});
} else {
markClean(postId);
}
}, [title, content, author, tags, selectedCategories, post, postId, isInitialized, isDirty, markDirty, markClean]);
}, [title, content, author, tags, selectedCategories, templateSlug, post, postId, isInitialized, isDirty, markDirty, markClean]);
// Handle editor mode change and persist preference
const handleEditorModeChange = (mode: EditorMode) => {
@@ -375,7 +388,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
author: author || undefined,
tags,
categories: selectedCategories.length > 0 ? selectedCategories : ['article'],
});
templateSlug: templateSlug || null,
} as Parameters<typeof window.electronAPI.posts.update>[1]);
if (updated) {
updatePost(postId, updated as Partial<PostData>);
@@ -799,6 +813,20 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
/>
</div>
</div>
{availablePostTemplates.length > 0 && (
<div className="editor-field">
<label>{tr('editor.field.template')}</label>
<select
value={templateSlug}
onChange={(e) => setTemplateSlug(e.target.value)}
>
<option value="">{tr('editor.field.templateDefault')}</option>
{availablePostTemplates.map((tmpl) => (
<option key={tmpl.slug} value={tmpl.slug}>{tmpl.title}</option>
))}
</select>
</div>
)}
<PostLinks
postId={postId}
@@ -1836,6 +1864,7 @@ export const Editor: React.FC = () => {
),
'site-validation': () => <SiteValidationView />,
scripts: () => <ScriptsView scriptId={editorRoute.tabId} />,
templates: () => <TemplatesView templateId={editorRoute.tabId} />,
post: () => (editorRoute.tabId ? <PostEditor key={editorRoute.tabId} postId={editorRoute.tabId} /> : <Dashboard />),
media: () => (editorRoute.tabId ? <MediaEditor key={editorRoute.tabId} mediaId={editorRoute.tabId} /> : <Dashboard />),
dashboard: () => <Dashboard />,

View File

@@ -34,6 +34,8 @@ interface CategoryMetadata {
renderInLists: boolean;
showTitle: boolean;
title: string;
postTemplateSlug?: string;
listTemplateSlug?: string;
}
const RENDER_LANGUAGE_LABEL_KEY: Record<SupportedLanguage, string> = {
@@ -151,6 +153,10 @@ export const SettingsView: React.FC = () => {
const [categoryMetadata, setCategoryMetadata] = useState<Record<string, CategoryMetadata>>(DEFAULT_CATEGORY_METADATA);
const [newCategoryInput, setNewCategoryInput] = useState('');
// Available templates for category dropdowns
const [postTemplates, setPostTemplates] = useState<Array<{ slug: string; title: string }>>([]);
const [listTemplates, setListTemplates] = useState<Array<{ slug: string; title: string }>>([]);
// AI Assistant settings
const [aiSystemPrompt, setAiSystemPrompt] = useState('');
const [aiSystemPromptModified, setAiSystemPromptModified] = useState(false);
@@ -221,6 +227,8 @@ export const SettingsView: React.FC = () => {
title: typeof (settings as any)?.title === 'string' && (settings as any).title.trim().length > 0
? (settings as any).title.trim()
: category,
postTemplateSlug: typeof (settings as any)?.postTemplateSlug === 'string' ? (settings as any).postTemplateSlug : undefined,
listTemplateSlug: typeof (settings as any)?.listTemplateSlug === 'string' ? (settings as any).listTemplateSlug : undefined,
};
}
}
@@ -230,6 +238,18 @@ export const SettingsView: React.FC = () => {
}
}, [activeProject]);
// Load available templates for category dropdowns
useEffect(() => {
if (activeProject) {
window.electronAPI?.templates.getEnabledByKind('post').then((templates) => {
setPostTemplates(templates.map((t) => ({ slug: t.slug, title: t.title })));
});
window.electronAPI?.templates.getEnabledByKind('list').then((templates) => {
setListTemplates(templates.map((t) => ({ slug: t.slug, title: t.title })));
});
}
}, [activeProject]);
// Load saved credentials and categories
useEffect(() => {
const loadSettings = async () => {
@@ -771,6 +791,29 @@ export const SettingsView: React.FC = () => {
}
};
const handleCategoryTemplateChange = async (
category: string,
field: 'postTemplateSlug' | 'listTemplateSlug',
value: string,
) => {
const nextCategoryMetadata: Record<string, CategoryMetadata> = {
...categoryMetadata,
[category]: {
...(categoryMetadata[category] || { renderInLists: true, showTitle: true, title: category }),
[field]: value || undefined,
},
};
setCategoryMetadata(nextCategoryMetadata);
try {
await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata });
} catch (error) {
console.error('Failed to update category settings:', error);
showToast.error(t('settings.toast.categorySettingsUpdateFailed'));
}
};
const renderContentSettings = () => (
<SettingSection
id="settings-section-content"
@@ -786,6 +829,8 @@ export const SettingsView: React.FC = () => {
<th>{t('settings.content.titleColumn')}</th>
<th>{t('settings.content.renderInLists')}</th>
<th>{t('settings.content.showTitles')}</th>
<th>{t('settings.content.postTemplateColumn')}</th>
<th>{t('settings.content.listTemplateColumn')}</th>
<th>{t('settings.content.actionsColumn')}</th>
</tr>
</thead>
@@ -823,6 +868,30 @@ export const SettingsView: React.FC = () => {
onChange={(event) => handleCategorySettingToggle(cat, 'showTitle', event.target.checked)}
/>
</td>
<td>
<select
value={metadata.postTemplateSlug || ''}
onChange={(event) => handleCategoryTemplateChange(cat, 'postTemplateSlug', event.target.value)}
aria-label={t('settings.content.postTemplateAria', { category: cat })}
>
<option value="">{t('editor.field.templateDefault')}</option>
{postTemplates.map((tpl) => (
<option key={tpl.slug} value={tpl.slug}>{tpl.title}</option>
))}
</select>
</td>
<td>
<select
value={metadata.listTemplateSlug || ''}
onChange={(event) => handleCategoryTemplateChange(cat, 'listTemplateSlug', event.target.value)}
aria-label={t('settings.content.listTemplateAria', { category: cat })}
>
<option value="">{t('editor.field.templateDefault')}</option>
{listTemplates.map((tpl) => (
<option key={tpl.slug} value={tpl.slug}>{tpl.title}</option>
))}
</select>
</td>
<td className="category-actions-cell">
{!isProtected && (
<button
@@ -1213,6 +1282,29 @@ export const SettingsView: React.FC = () => {
</button>
</SettingRow>
<SettingRow
id="rebuild-templates"
label={t('settings.data.rebuildTemplatesLabel')}
description={t('settings.data.rebuildTemplatesDescription')}
>
<button
className="secondary"
onClick={async () => {
showToast.loading(t('settings.toast.rebuildTemplatesLoading'));
try {
await window.electronAPI?.templates.rebuildFromFiles();
showToast.dismiss();
showToast.success(t('settings.toast.rebuildTemplatesSuccess'));
} catch {
showToast.dismiss();
showToast.error(t('settings.toast.rebuildTemplatesFailed'));
}
}}
>
{t('settings.data.rebuildTemplatesAction')}
</button>
</SettingRow>
<SettingRow
id="rebuild-links"
label={t('settings.data.rebuildLinksLabel')}

View File

@@ -1,14 +1,14 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useAppStore, PostData, MediaData } from '../../store';
import { showToast } from '../Toast';
import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent, getContrastColor, groupPostsByStatus, loadTagColorMap } from '../../utils';
import { BDS_EVENT_SCRIPTS_CHANGED, BDS_EVENT_TEMPLATES_CHANGED, dispatchWindowEvent, getContrastColor, groupPostsByStatus, loadTagColorMap } from '../../utils';
import type { ChatConversation, ImportDefinitionData } from '../../types/electron';
import { GitSidebar } from '../GitSidebar/GitSidebar';
import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView';
import { scrollToTagsSection, TagsCategory } from '../TagsView';
import { activateSidebarSection } from '../../navigation/sectionActivation';
import { getPersistedSidebarSection, setPersistedSidebarSection } from '../../navigation/sidebarUiPersistence';
import { openChatTab, openEntityTab, openImportTab, openScriptTab, openSingletonToolTab } from '../../navigation/tabPolicy';
import { openChatTab, openEntityTab, openImportTab, openScriptTab, openTemplateTab, openSingletonToolTab } from '../../navigation/tabPolicy';
import { createAndFocusPost } from '../../navigation/postCreation';
import type { SidebarView } from '../../navigation/sidebarViewRegistry';
import { useI18n } from '../../i18n';
@@ -1702,6 +1702,120 @@ const ScriptsList: React.FC = () => {
);
};
const TemplatesList: React.FC = () => {
const { t, language } = useI18n();
const { openTab, activeTabId, closeTab } = useAppStore();
const activeProjectId = useAppStore((state) => state.activeProject?.id);
const loadTemplates = useCallback(async (): Promise<Array<{ id: string; title: string; updatedAt: string }>> => {
const items = await window.electronAPI?.templates.getAll();
return (items ?? []).map((item) => ({ id: item.id, title: item.title, updatedAt: item.updatedAt }));
}, []);
const {
items: templates,
setItems: setTemplates,
isLoading,
reload: reloadTemplates,
} = useProjectScopedSidebarData<Array<{ id: string; title: string; updatedAt: string }>[number]>({
load: loadTemplates,
activeProjectId,
refreshEventName: BDS_EVENT_TEMPLATES_CHANGED,
});
const handleCreateTemplate = async () => {
try {
const created = await window.electronAPI?.templates.create({
title: t('sidebar.templates.newTemplate'),
kind: 'post',
content: '',
enabled: true,
});
if (!created) {
return;
}
setTemplates((prev) => [
{ id: created.id, title: created.title, updatedAt: created.updatedAt },
...prev.filter((tmpl) => tmpl.id !== created.id),
]);
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
openTemplateTab(openTab, created.id, 'pin');
void reloadTemplates();
} catch (error) {
console.error('Failed to create template:', error);
showToast.error(t('sidebar.templates.createFailed'));
}
};
const handleDeleteTemplate = async (event: React.MouseEvent, templateId: string) => {
event.stopPropagation();
try {
const deleted = await window.electronAPI?.templates.delete(templateId);
if (!deleted) {
showToast.error(t('sidebar.templates.deleteFailed'));
return;
}
setTemplates((prev) => prev.filter((tmpl) => tmpl.id !== templateId));
closeTab(templateId);
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
} catch (error) {
console.error('Failed to delete template:', error);
showToast.error(t('sidebar.templates.deleteFailed'));
}
};
return (
<SidebarEntityList
header={t('sidebar.templates.header')}
createTitle={t('sidebar.templates.newTemplate')}
onCreate={handleCreateTemplate}
isLoading={isLoading}
loadingLabel={t('sidebar.loading')}
emptyMessage={t('sidebar.templates.none')}
emptyActionLabel={t('sidebar.templates.createTemplate')}
onEmptyAction={handleCreateTemplate}
items={templates}
getItemKey={(tmpl) => tmpl.id}
renderItem={(tmpl) => (
<div
role="button"
tabIndex={0}
aria-label={tmpl.title}
className={`chat-list-item ${activeTabId === tmpl.id ? 'active' : ''}`}
onClick={() => openTemplateTab(openTab, tmpl.id, 'preview')}
onDoubleClick={() => openTemplateTab(openTab, tmpl.id, 'pin')}
onKeyDown={(event) => {
if (event.key === 'Enter') {
openTemplateTab(openTab, tmpl.id, 'pin');
return;
}
if (event.key === ' ') {
event.preventDefault();
openTemplateTab(openTab, tmpl.id, 'preview');
}
}}
>
<div className="chat-item-content">
<div className="chat-item-title">{tmpl.title}</div>
<div className="chat-item-date">
{formatSidebarRelativeDate({ dateString: tmpl.updatedAt, language, t })}
</div>
</div>
<button
className="chat-item-delete"
onClick={(event) => handleDeleteTemplate(event, tmpl.id)}
title={t('sidebar.templates.deleteTemplate')}
>
×
</button>
</div>
)}
/>
);
};
export const Sidebar: React.FC = () => {
const { activeView, sidebarVisible } = useAppStore();
@@ -1714,6 +1828,7 @@ export const Sidebar: React.FC = () => {
pages: <PostsList mode="pages" isActive={true} />,
media: <MediaList />,
scripts: <ScriptsList />,
templates: <TemplatesList />,
settings: <SettingsNav />,
tags: <TagsNav />,
chat: <ChatList />,

View File

@@ -18,6 +18,7 @@ interface TagData {
projectId: string;
name: string;
color?: string;
postTemplateSlug?: string;
createdAt: string;
updatedAt: string;
}
@@ -147,6 +148,8 @@ export const TagsView: React.FC = () => {
const [editingTagId, setEditingTagId] = useState<string | null>(null);
const [editTagColor, setEditTagColor] = useState<string>('');
const [editTagName, setEditTagName] = useState('');
const [editTagTemplate, setEditTagTemplate] = useState<string>('');
const [postTemplates, setPostTemplates] = useState<Array<{ slug: string; title: string }>>([]);
// Merge tags state
const [mergeTargetName, setMergeTargetName] = useState('');
@@ -188,6 +191,13 @@ export const TagsView: React.FC = () => {
});
}, [loadTags]);
// Load post templates on mount
useEffect(() => {
window.electronAPI?.templates.getEnabledByKind('post').then((templates) => {
setPostTemplates(templates.map((t) => ({ slug: t.slug, title: t.title })));
});
}, []);
// Handle tag selection
const handleTagSelect = (name: string) => {
setSelectedTags(prev => {
@@ -247,6 +257,7 @@ export const TagsView: React.FC = () => {
setEditingTagId(tag.id);
setEditTagColor(tag.color || '');
setEditTagName(tag.name);
setEditTagTemplate(tag.postTemplateSlug || '');
};
// Save tag edit
@@ -254,9 +265,10 @@ export const TagsView: React.FC = () => {
if (!editingTagId) return;
try {
// Update color
// Update color and template
await window.electronAPI?.tags.update(editingTagId, {
color: editTagColor || null,
postTemplateSlug: editTagTemplate || null,
});
// If name changed, rename the tag
@@ -455,6 +467,7 @@ export const TagsView: React.FC = () => {
<div className="tag-edit-form">
<h4>{t('tagsView.edit.title', { name: selectedTagObjects[0].name })}</h4>
{editingTagId === selectedTagObjects[0].id ? (
<>
<div className="tag-form-row">
<input
type="text"
@@ -469,8 +482,8 @@ export const TagsView: React.FC = () => {
onChange={(e) => setEditTagColor(e.target.value)}
/>
{editTagColor && (
<button
className="clear-color"
<button
className="clear-color"
onClick={() => setEditTagColor('')}
title={t('tagsView.removeColor')}
>
@@ -481,6 +494,14 @@ export const TagsView: React.FC = () => {
<button onClick={handleSaveEdit} className="primary">{t('common.save')}</button>
<button onClick={() => setEditingTagId(null)}>{t('common.cancel')}</button>
</div>
<div className="tagsview-field">
<label>{t('tagsView.edit.postTemplate')}</label>
<select value={editTagTemplate} onChange={(e) => setEditTagTemplate(e.target.value)}>
<option value="">{t('editor.field.templateDefault')}</option>
{postTemplates.map(tmpl => <option key={tmpl.slug} value={tmpl.slug}>{tmpl.title}</option>)}
</select>
</div>
</>
) : (
<div className="tag-form-row">
<span className="tag-preview" style={

View File

@@ -0,0 +1,54 @@
.templates-view-shell {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.templates-view {
flex: 1;
min-height: 0;
}
.templates-meta-row {
margin-bottom: 8px;
}
.templates-enabled-field {
align-self: flex-end;
}
.templates-enabled-field label {
display: flex;
align-items: center;
gap: 8px;
}
.templates-editor {
flex: 1;
min-height: 0;
}
.templates-toolbar {
margin-bottom: 8px;
}
.templates-monaco {
flex: 1;
min-height: 0;
border-radius: 4px;
overflow: hidden;
background-color: var(--vscode-input-background);
}
.templates-save-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
}
.templates-validate-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
}

View File

@@ -0,0 +1,360 @@
import React, { useEffect, useState } from 'react';
import MonacoEditor from '@monaco-editor/react';
import type { TemplateData, TemplateKind } from '../../../main/shared/electronApi';
import { useAppStore } from '../../store';
import { BDS_EVENT_TEMPLATES_CHANGED, dispatchWindowEvent } from '../../utils';
import { useI18n } from '../../i18n';
import { showToast } from '../Toast';
import './TemplatesView.css';
const UI_DATE_LOCALE: Record<string, string> = {
en: 'en-US',
de: 'de-DE',
fr: 'fr-FR',
it: 'it-IT',
es: 'es-ES',
};
const toTemplateSlug = (value: string) => {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'template';
};
interface TemplatesViewProps {
templateId: string | null;
}
export const TemplatesView: React.FC<TemplatesViewProps> = ({ templateId }) => {
const { t, language } = useI18n();
const closeTab = useAppStore((state) => state.closeTab);
const [template, setTemplate] = useState<TemplateData | null>(null);
const [title, setTitle] = useState('');
const [slug, setSlug] = useState('');
const [kind, setKind] = useState<TemplateKind>('post');
const [enabled, setEnabled] = useState(true);
const [templateContent, setTemplateContent] = useState('');
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [monacoResetToken, setMonacoResetToken] = useState(0);
useEffect(() => {
let cancelled = false;
const loadTemplate = async () => {
if (!templateId) {
setTemplate(null);
setTitle('');
setSlug('');
setKind('post');
setEnabled(true);
setTemplateContent('');
setMonacoResetToken((prev) => prev + 1);
setIsSlugManuallyEdited(false);
return;
}
const item = await window.electronAPI?.templates.get(templateId);
if (cancelled || !item) {
setTemplate(null);
setTitle('');
setSlug('');
setKind('post');
setEnabled(true);
setTemplateContent('');
setMonacoResetToken((prev) => prev + 1);
setIsSlugManuallyEdited(false);
return;
}
setTemplate(item);
setTitle(item.title || '');
setSlug(item.slug || toTemplateSlug(item.title || ''));
setKind(item.kind || 'post');
setEnabled(item.enabled ?? true);
setTemplateContent(item.content || '');
setMonacoResetToken((prev) => prev + 1);
const normalizedExisting = toTemplateSlug(item.slug || item.title || '');
setIsSlugManuallyEdited(normalizedExisting !== toTemplateSlug(item.title || ''));
};
void loadTemplate();
return () => {
cancelled = true;
};
}, [templateId]);
const hasChanges =
!!template &&
(title !== template.title ||
slug !== template.slug ||
kind !== template.kind ||
enabled !== template.enabled ||
templateContent !== template.content);
const handleTitleChange = (nextTitle: string) => {
setTitle(nextTitle);
if (!isSlugManuallyEdited) {
setSlug(toTemplateSlug(nextTitle));
}
};
const handleSlugChange = (nextSlug: string) => {
setIsSlugManuallyEdited(true);
setSlug(toTemplateSlug(nextSlug));
};
const handleValidate = async (options: { notify: boolean } = { notify: true }): Promise<boolean> => {
if (!template || isValidating) {
return false;
}
setIsValidating(true);
try {
const result = await window.electronAPI?.templates.validate(templateContent);
if (!result) {
return false;
}
if (!result.valid) {
if (options.notify) {
showToast.error(t('templates.validate.invalid', { count: result.errors.length }));
}
return false;
}
if (options.notify) {
showToast.success(t('templates.validate.valid'));
}
return true;
} catch {
return false;
} finally {
setIsValidating(false);
}
};
const handleSaveTemplate = async () => {
if (!template || isSaving || !hasChanges) {
return;
}
setIsSaving(true);
try {
const isValid = await handleValidate({ notify: true });
if (!isValid) {
return;
}
const updated = await window.electronAPI?.templates.update(template.id, {
title,
slug,
kind,
enabled,
content: templateContent,
});
if (!updated) {
return;
}
setTemplate(updated);
setTitle(updated.title || '');
setSlug(updated.slug || toTemplateSlug(updated.title || ''));
setKind(updated.kind || 'post');
setEnabled(updated.enabled ?? true);
setTemplateContent(updated.content || '');
const normalizedExisting = toTemplateSlug(updated.slug || updated.title || '');
setIsSlugManuallyEdited(normalizedExisting !== toTemplateSlug(updated.title || ''));
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
} finally {
setIsSaving(false);
}
};
const handleDeleteTemplate = async () => {
if (!template) {
return;
}
try {
const deleted = await window.electronAPI?.templates.delete(template.id);
if (!deleted) {
showToast.error(t('sidebar.templates.deleteFailed'));
return;
}
closeTab(template.id);
dispatchWindowEvent(BDS_EVENT_TEMPLATES_CHANGED);
} catch (error) {
console.error('Failed to delete template:', error);
showToast.error(t('sidebar.templates.deleteFailed'));
}
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault();
void handleSaveTemplate();
}
};
if (typeof window.addEventListener !== 'function' || typeof window.removeEventListener !== 'function') {
return;
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSaveTemplate]);
return (
<div className="templates-view-shell">
<div className="editor-header templates-header">
<div className="editor-tabs">
<div className="editor-tab active">
<span className="editor-tab-title">{title || t('editor.untitled')}</span>
</div>
</div>
<div className="editor-actions">
<button
type="button"
className="templates-save-button"
onClick={handleSaveTemplate}
disabled={!template || !hasChanges || isSaving}
>
{isSaving ? t('editor.saving') : t('templates.save')}
</button>
<button
type="button"
className="templates-validate-button"
onClick={() => {
void handleValidate({ notify: true });
}}
disabled={!template || isValidating || isSaving}
>
{isValidating ? t('templates.validate.checking') : t('templates.validate')}
</button>
<button
type="button"
className="secondary danger"
onClick={handleDeleteTemplate}
disabled={!template}
title={t('templates.delete')}
>
{t('templates.delete')}
</button>
</div>
</div>
<div className="editor-content templates-view">
<div className="editor-header-row templates-meta-row">
<div className="editor-meta">
<div className="editor-field-row">
<div className="editor-field">
<label htmlFor="template-title">{t('editor.field.title')}</label>
<input
id="template-title"
type="text"
value={title}
onChange={(event) => handleTitleChange(event.target.value)}
disabled={!template}
/>
</div>
<div className="editor-field">
<label htmlFor="template-slug">{t('editor.field.slug')}</label>
<input
id="template-slug"
type="text"
value={slug}
onChange={(event) => handleSlugChange(event.target.value)}
disabled={!template}
/>
</div>
</div>
<div className="editor-field-row">
<div className="editor-field">
<label htmlFor="template-kind">{t('templates.field.kind')}</label>
<select
id="template-kind"
value={kind}
onChange={(event) => setKind(event.target.value as TemplateKind)}
disabled={!template}
>
<option value="post">{t('templates.kind.post')}</option>
<option value="list">{t('templates.kind.list')}</option>
<option value="not-found">{t('templates.kind.not_found')}</option>
<option value="partial">{t('templates.kind.partial')}</option>
</select>
</div>
<div className="editor-field templates-enabled-field">
<label htmlFor="template-enabled">
<input
id="template-enabled"
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
disabled={!template}
/>
{t('templates.field.enabled')}
</label>
</div>
</div>
</div>
</div>
<div className="editor-body templates-editor">
<div className="editor-toolbar templates-toolbar">
<div className="editor-toolbar-left">
<label>{t('templates.content')}</label>
</div>
<div className="editor-toolbar-center" />
<div className="editor-toolbar-right" />
</div>
<div className="templates-monaco">
<MonacoEditor
key={monacoResetToken}
height="100%"
language="html"
theme="vs-dark"
defaultValue={templateContent}
onChange={(value) => setTemplateContent(value || '')}
options={{
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
fontSize: 14,
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
padding: { top: 12, bottom: 12 },
automaticLayout: true,
scrollBeyondLastLine: false,
renderLineHighlight: 'line',
formatOnPaste: true,
cursorStyle: 'line',
cursorBlinking: 'smooth',
readOnly: !template,
}}
/>
</div>
</div>
{template && (
<div className="editor-footer">
<span className="text-muted text-small">
{t('editor.footer.created')}:{' '}
{new Date(template.createdAt).toLocaleString(UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en)}
</span>
<span className="text-muted text-small">
{t('editor.footer.updated')}:{' '}
{new Date(template.updatedAt).toLocaleString(UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en)}
</span>
</div>
)}
</div>
</div>
);
};