Move template management into Entries side panel

This commit is contained in:
Jacob Schmidt 2026-02-27 06:29:05 -06:00
parent 64c06081f0
commit a72ddf6aec
3 changed files with 288 additions and 202 deletions

View File

@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import { listEntryTemplates, type EntryTemplateItemDto } from "$lib/backend/templates";
import CalendarWidget from "$lib/components/CalendarWidget.svelte"; import CalendarWidget from "$lib/components/CalendarWidget.svelte";
import { entriesStore } from "$lib/stores/entries"; import { entriesBusyStore, entriesStore } from "$lib/stores/entries";
import { createFragmentDraft, fragmentsStore } from "$lib/stores/fragments"; import { createFragmentDraft, fragmentsStore } from "$lib/stores/fragments";
import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists"; import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists";
import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos"; import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
export let activeSection = "entries"; export let activeSection = "entries";
export let activeDocumentId = ""; export let activeDocumentId = "";
export let templateRefreshToken = 0;
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {}; export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
export let onEditItem: (doc: { id: string; label: string; initialContent: string }) => void = () => {}; export let onEditItem: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
export let onDeleteItem: (doc: { id: string; label: string }) => void = () => {}; export let onDeleteItem: (doc: { id: string; label: string }) => void = () => {};
@ -14,6 +16,7 @@
let showNewItemInput = false; let showNewItemInput = false;
let newItemName = ""; let newItemName = "";
let newItemInput: HTMLInputElement | null = null; let newItemInput: HTMLInputElement | null = null;
let createTemplateMode = false;
type SidePanelItem = { type SidePanelItem = {
id: string; id: string;
@ -22,6 +25,11 @@
}; };
let todoDocuments: SidePanelItem[] = []; let todoDocuments: SidePanelItem[] = [];
let customCalendarEntries: SidePanelItem[] = []; let customCalendarEntries: SidePanelItem[] = [];
let templateItems: SidePanelItem[] = [];
let templatesBusy = false;
let templateError = "";
let wasEntriesSection = false;
let lastTemplateRefreshToken = -1;
const sectionTitles: Record<string, string> = { const sectionTitles: Record<string, string> = {
entries: "Entries", entries: "Entries",
@ -62,6 +70,37 @@
selectedCalendarDate = payload; selectedCalendarDate = payload;
} }
function toTemplateStoreId(filePath: string): string {
return `entries/template-file/${encodeURIComponent(filePath)}`;
}
function toTemplateLabel(fileName: string): string {
return fileName.replace(/\.template\.md$/i, "");
}
function mapTemplateDto(item: EntryTemplateItemDto): SidePanelItem {
return {
id: toTemplateStoreId(item.filePath),
label: toTemplateLabel(item.fileName),
initialContent: ""
};
}
async function refreshTemplates() {
if (activeSection !== "entries") return;
templatesBusy = true;
templateError = "";
try {
const templates = await listEntryTemplates();
templateItems = templates.map(mapTemplateDto);
} catch (error) {
templateError = String(error);
templateItems = [];
} finally {
templatesBusy = false;
}
}
function daysInMonth(year: number, month: number): number { function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate(); return new Date(year, month + 1, 0).getDate();
} }
@ -126,6 +165,9 @@
function handleAddItem() { function handleAddItem() {
if (activeSection === "entries" || activeSection === "todos" || activeSection === "lists") { if (activeSection === "entries" || activeSection === "todos" || activeSection === "lists") {
if (activeSection === "entries") {
createTemplateMode = false;
}
showNewItemInput = true; showNewItemInput = true;
newItemName = ""; newItemName = "";
queueMicrotask(() => newItemInput?.focus()); queueMicrotask(() => newItemInput?.focus());
@ -158,6 +200,14 @@
} }
} }
function handleAddTemplate() {
if (activeSection !== "entries") return;
createTemplateMode = true;
showNewItemInput = true;
newItemName = "";
queueMicrotask(() => newItemInput?.focus());
}
async function confirmNewItem() { async function confirmNewItem() {
const label = newItemName.trim(); const label = newItemName.trim();
if (!label) { if (!label) {
@ -168,10 +218,17 @@
newItemName = ""; newItemName = "";
if (activeSection === "entries") { if (activeSection === "entries") {
const id = `entries/draft-${Date.now()}`; const isTemplate = createTemplateMode;
const item = { id, label, initialContent: `# ${label}\n\n` }; const displayLabel = isTemplate
? label.endsWith("_template")
? label
: `${label}_template`
: label;
const id = isTemplate ? `entries/template-draft-${Date.now()}` : `entries/draft-${Date.now()}`;
const item = { id, label: displayLabel, initialContent: `# ${displayLabel}\n\n` };
entriesStore.update((items) => [item, ...items]); entriesStore.update((items) => [item, ...items]);
onEditItem(item); onEditItem(item);
createTemplateMode = false;
} else if (activeSection === "todos") { } else if (activeSection === "todos") {
try { try {
const { meta, items: todoItems } = await createTodoListFromLabel(label); const { meta, items: todoItems } = await createTodoListFromLabel(label);
@ -208,6 +265,7 @@
function cancelNewItem() { function cancelNewItem() {
showNewItemInput = false; showNewItemInput = false;
newItemName = ""; newItemName = "";
createTemplateMode = false;
} }
function handleNewItemKeydown(event: KeyboardEvent) { function handleNewItemKeydown(event: KeyboardEvent) {
@ -237,14 +295,38 @@
$: isCalendarSection = activeSection === "calendar"; $: isCalendarSection = activeSection === "calendar";
$: calendarEntries = [...customCalendarEntries, ...getCalendarEntries(calendarYear, calendarMonth)]; $: calendarEntries = [...customCalendarEntries, ...getCalendarEntries(calendarYear, calendarMonth)];
$: showItemActions = activeSection === "entries" || activeSection === "todos" || activeSection === "lists" || activeSection === "fragments"; $: showItemActions = activeSection === "entries" || activeSection === "todos" || activeSection === "lists" || activeSection === "fragments";
$: entryItems = activeSection === "entries"
? $entriesStore.filter((item) => !item.id.startsWith("entries/template-draft-"))
: [];
$: templateDraftItems = activeSection === "entries"
? $entriesStore.filter((item) => item.id.startsWith("entries/template-draft-"))
: [];
$: allTemplateItems = [...templateDraftItems, ...templateItems];
$: if (activeSection === "entries" && (!wasEntriesSection || templateRefreshToken !== lastTemplateRefreshToken)) {
wasEntriesSection = true;
lastTemplateRefreshToken = templateRefreshToken;
void refreshTemplates();
}
$: if (activeSection !== "entries") {
wasEntriesSection = false;
}
</script> </script>
<section class="side-panel" aria-label="Section panel"> <section class="side-panel" aria-label="Section panel">
<header class="panel-header"> <header class="panel-header">
<h2>{panelTitle}</h2> <h2>{panelTitle}</h2>
<button type="button" class="panel-action" aria-label="Add item" on:click={handleAddItem}> <div class="panel-header-actions">
<span class="material-symbols-outlined">add</span> {#if activeSection === "entries"}
</button> <button type="button" class="panel-action" aria-label="Add template" title="Add template" on:click={handleAddTemplate}>
<span class="material-symbols-outlined">palette</span>
</button>
{/if}
<button type="button" class="panel-action" aria-label="Add item" title="Add item" on:click={handleAddItem}>
<span class="material-symbols-outlined">add</span>
</button>
</div>
</header> </header>
{#if isCalendarSection} {#if isCalendarSection}
@ -281,36 +363,111 @@
type="text" type="text"
bind:this={newItemInput} bind:this={newItemInput}
bind:value={newItemName} bind:value={newItemName}
placeholder={activeSection === "entries" ? "Entry name..." : activeSection === "todos" ? "Todo list name..." : "List name..."} placeholder={activeSection === "entries"
? createTemplateMode
? "Template name..."
: "Entry name..."
: activeSection === "todos"
? "Todo list name..."
: "List name..."}
on:keydown={handleNewItemKeydown} on:keydown={handleNewItemKeydown}
on:blur={confirmNewItem} on:blur={confirmNewItem}
/> />
</div> </div>
{/if} {/if}
<ul class="panel-list"> {#if activeSection === "entries"}
{#each items as item} <div class="panel-subsection">
<li class:is-active={item.id === activeDocumentId}> <h3>Entries</h3>
<button {#if $entriesBusyStore}
type="button" <p class="section-copy">Loading entries...</p>
class="item-label" {:else}
on:click={() => onOpenDocument(item)} <ul class="panel-list">
> {#each entryItems as item}
{item.label} <li class:is-active={item.id === activeDocumentId}>
</button> <button
{#if showItemActions} type="button"
<div class="item-actions"> class="item-label"
<button type="button" class="item-action" on:click|stopPropagation={() => onEditItem(item)} aria-label="Edit"> on:click={() => onOpenDocument(item)}
<span class="material-symbols-outlined">edit</span> >
</button> {item.label}
<button type="button" class="item-action item-action-danger" on:click|stopPropagation={() => onDeleteItem(item)} aria-label="Delete"> </button>
<span class="material-symbols-outlined">delete</span> <div class="item-actions">
</button> <button type="button" class="item-action" on:click|stopPropagation={() => onEditItem(item)} aria-label="Edit">
</div> <span class="material-symbols-outlined">edit</span>
</button>
<button type="button" class="item-action item-action-danger" on:click|stopPropagation={() => onDeleteItem(item)} aria-label="Delete">
<span class="material-symbols-outlined">delete</span>
</button>
</div>
</li>
{/each}
</ul>
{#if !entryItems.length}
<p class="section-copy">No entries found.</p>
{/if} {/if}
</li> {/if}
{/each} </div>
</ul>
<div class="panel-subsection">
<h3>Templates</h3>
{#if templatesBusy}
<p class="section-copy">Loading templates...</p>
{:else}
<ul class="panel-list">
{#each allTemplateItems as item}
<li class:is-active={item.id === activeDocumentId}>
<button
type="button"
class="item-label"
on:click={() => onOpenDocument(item)}
>
{item.label}
</button>
<div class="item-actions">
<button type="button" class="item-action" on:click|stopPropagation={() => onEditItem(item)} aria-label="Edit">
<span class="material-symbols-outlined">edit</span>
</button>
<button type="button" class="item-action item-action-danger" on:click|stopPropagation={() => onDeleteItem(item)} aria-label="Delete">
<span class="material-symbols-outlined">delete</span>
</button>
</div>
</li>
{/each}
</ul>
{#if !allTemplateItems.length}
<p class="section-copy">No templates found.</p>
{/if}
{/if}
{#if templateError}
<p class="template-error">{templateError}</p>
{/if}
</div>
{:else}
<ul class="panel-list">
{#each items as item}
<li class:is-active={item.id === activeDocumentId}>
<button
type="button"
class="item-label"
on:click={() => onOpenDocument(item)}
>
{item.label}
</button>
{#if showItemActions}
<div class="item-actions">
<button type="button" class="item-action" on:click|stopPropagation={() => onEditItem(item)} aria-label="Edit">
<span class="material-symbols-outlined">edit</span>
</button>
<button type="button" class="item-action item-action-danger" on:click|stopPropagation={() => onDeleteItem(item)} aria-label="Delete">
<span class="material-symbols-outlined">delete</span>
</button>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
{/if} {/if}
</section> </section>
@ -332,6 +489,12 @@
justify-content: space-between; justify-content: space-between;
} }
.panel-header-actions {
display: flex;
align-items: center;
gap: 6px;
}
.panel-header h2 { .panel-header h2 {
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
@ -481,6 +644,30 @@
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
.panel-subsection {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
}
.panel-subsection h3 {
font-size: 0.78rem;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.01em;
}
.section-copy {
color: var(--text-dim);
font-size: 0.78rem;
}
.template-error {
color: #e74c3c;
font-size: 0.78rem;
}
.new-item-input { .new-item-input {
padding: 0 2px; padding: 0 2px;

View File

@ -2,6 +2,7 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { unlockVaultWorkspace } from "$lib/backend/auth"; import { unlockVaultWorkspace } from "$lib/backend/auth";
import AppModal from "$lib/components/AppModal.svelte"; import AppModal from "$lib/components/AppModal.svelte";
import { deleteEntryTemplate, loadEntryTemplate, saveEntryTemplate } from "$lib/backend/templates";
import { deleteEntryByStoreId, entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId, saveEntryFromStore } from "$lib/stores/entries"; import { deleteEntryByStoreId, entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId, saveEntryFromStore } from "$lib/stores/entries";
import { deleteFragmentByStoreId, hydrateFragments } from "$lib/stores/fragments"; import { deleteFragmentByStoreId, hydrateFragments } from "$lib/stores/fragments";
import { deleteListByStoreId, hydrateLists, updateListByStoreId } from "$lib/stores/lists"; import { deleteListByStoreId, hydrateLists, updateListByStoreId } from "$lib/stores/lists";
@ -45,6 +46,25 @@
let unlockResolver: ((password: string | null) => void) | null = null; let unlockResolver: ((password: string | null) => void) | null = null;
let fragmentBootstrapInFlight = false; let fragmentBootstrapInFlight = false;
let pendingDeleteItemId = ""; let pendingDeleteItemId = "";
let templateRefreshToken = 0;
function toTemplatePath(id: string): string | null {
const prefix = "entries/template-file/";
if (!id.startsWith(prefix)) return null;
const encoded = id.slice(prefix.length).trim();
if (!encoded) return null;
try {
const decoded = decodeURIComponent(encoded);
return decoded || null;
} catch {
return null;
}
}
function templateNameFromPath(path: string): string {
const fileName = path.split(/[\\/]/).pop() ?? path;
return fileName.replace(/\.template\.md$/i, "");
}
function showModal(options: { function showModal(options: {
action: "logout-confirm" | "logout-info" | "unlock-vault" | "delete-confirm"; action: "logout-confirm" | "logout-info" | "unlock-vault" | "delete-confirm";
@ -160,6 +180,7 @@
if (isVaultReady()) { if (isVaultReady()) {
try { try {
await hydrateEntries(); await hydrateEntries();
templateRefreshToken += 1;
const firstEntry = getDefaultEntry(get(entriesStore)); const firstEntry = getDefaultEntry(get(entriesStore));
if (firstEntry && activeDocumentId === "entries/daily-notes") { if (firstEntry && activeDocumentId === "entries/daily-notes") {
await handleOpenDocument(firstEntry); await handleOpenDocument(firstEntry);
@ -185,6 +206,7 @@
setVaultSession(password); setVaultSession(password);
await hydrateEntries(); await hydrateEntries();
templateRefreshToken += 1;
const firstEntry = getDefaultEntry(get(entriesStore)); const firstEntry = getDefaultEntry(get(entriesStore));
if (firstEntry && activeDocumentId === "entries/daily-notes") { if (firstEntry && activeDocumentId === "entries/daily-notes") {
await handleOpenDocument(firstEntry); await handleOpenDocument(firstEntry);
@ -211,7 +233,31 @@
if (!content?.trim()) return; if (!content?.trim()) return;
try { try {
if (selectedSection === "entries" && (activeDocumentId.startsWith("entries/file/") || activeDocumentId.startsWith("entries/draft-"))) { if (selectedSection === "entries" && activeDocumentId.startsWith("entries/template-draft-")) {
const draft = get(entriesStore).find((item) => item.id === activeDocumentId);
const draftLabel = (draft?.label ?? activeDocumentLabel ?? "").trim();
const templateName = draftLabel.replace(/_template$/i, "").trim() || draftLabel;
await saveEntryTemplate({
name: templateName,
content
});
templateRefreshToken += 1;
entriesStore.update((items) => items.filter((item) => item.id !== activeDocumentId));
const { [activeDocumentId]: _, ...rest } = openDocuments;
openDocuments = rest;
activeDocumentId = "";
activeDocumentLabel = "";
editMode = false;
} else if (selectedSection === "entries" && activeDocumentId.startsWith("entries/template-file/")) {
const filePath = toTemplatePath(activeDocumentId);
if (!filePath) return;
await saveEntryTemplate({
name: templateNameFromPath(filePath),
content,
filePath
});
templateRefreshToken += 1;
} else if (selectedSection === "entries" && (activeDocumentId.startsWith("entries/file/") || activeDocumentId.startsWith("entries/draft-"))) {
const saved = await saveEntryFromStore(activeDocumentId, content, "Overwrite"); const saved = await saveEntryFromStore(activeDocumentId, content, "Overwrite");
if (saved && saved.id !== activeDocumentId) { if (saved && saved.id !== activeDocumentId) {
const { [activeDocumentId]: _, ...rest } = openDocuments; const { [activeDocumentId]: _, ...rest } = openDocuments;
@ -281,6 +327,20 @@
} catch { } catch {
// entry content will use initialContent fallback // entry content will use initialContent fallback
} }
} else if (selectedSection === "entries" && doc.id.startsWith("entries/template-file/") && !doc.initialContent) {
try {
const filePath = toTemplatePath(doc.id);
if (filePath) {
const loaded = await loadEntryTemplate(filePath);
resolvedDoc = {
id: doc.id,
label: loaded.fileName.replace(/\.template\.md$/i, ""),
initialContent: loaded.content
};
}
} catch {
// template content will use initialContent fallback
}
} }
if (!(resolvedDoc.id in openDocuments)) { if (!(resolvedDoc.id in openDocuments)) {
@ -303,7 +363,16 @@
try { try {
let ok = false; let ok = false;
if (selectedSection === "entries") { if (selectedSection === "entries") {
ok = await deleteEntryByStoreId(id); const templatePath = toTemplatePath(id);
if (templatePath) {
ok = await deleteEntryTemplate(templatePath);
if (ok) templateRefreshToken += 1;
} else if (id.startsWith("entries/template-draft-")) {
entriesStore.update((items) => items.filter((item) => item.id !== id));
ok = true;
} else {
ok = await deleteEntryByStoreId(id);
}
} else if (selectedSection === "todos") { } else if (selectedSection === "todos") {
ok = await deleteTodoListByStoreId(id); ok = await deleteTodoListByStoreId(id);
} else if (selectedSection === "lists") { } else if (selectedSection === "lists") {
@ -356,6 +425,7 @@
<SidePanel <SidePanel
activeSection={selectedSection} activeSection={selectedSection}
{activeDocumentId} {activeDocumentId}
{templateRefreshToken}
onOpenDocument={handleOpenDocument} onOpenDocument={handleOpenDocument}
onEditItem={handleEditItem} onEditItem={handleEditItem}
onDeleteItem={handleDeleteItem} onDeleteItem={handleDeleteItem}
@ -402,4 +472,3 @@
min-width: 0; min-width: 0;
} }
</style> </style>

View File

@ -2,13 +2,6 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import AppModal from "$lib/components/AppModal.svelte"; import AppModal from "$lib/components/AppModal.svelte";
import Navbar from "$lib/components/Navbar.svelte"; import Navbar from "$lib/components/Navbar.svelte";
import {
deleteEntryTemplate,
listEntryTemplates,
loadEntryTemplate,
saveEntryTemplate,
type EntryTemplateItemDto
} from "$lib/backend/templates";
import { import {
addFragmentType, addFragmentType,
addSettingsTag, addSettingsTag,
@ -41,12 +34,6 @@
let sidecarRoot = ""; let sidecarRoot = "";
let sidecarRootIsCustom = false; let sidecarRootIsCustom = false;
let sidecarRootError = ""; let sidecarRootError = "";
let templates: EntryTemplateItemDto[] = [];
let templateName = "";
let templateContent = "";
let templateFilePath: string | null = null;
let templateError = "";
let templatesBusy = false;
onMount(async () => { onMount(async () => {
try { try {
@ -56,8 +43,6 @@
} catch (e) { } catch (e) {
sidecarRootError = String(e); sidecarRootError = String(e);
} }
await refreshTemplates();
}); });
async function saveSidecarRoot() { async function saveSidecarRoot() {
@ -208,83 +193,6 @@
} }
} }
async function refreshTemplates() {
templatesBusy = true;
templateError = "";
try {
templates = await listEntryTemplates();
} catch (error) {
templateError = String(error);
} finally {
templatesBusy = false;
}
}
function resetTemplateEditor() {
templateName = "";
templateContent = "";
templateFilePath = null;
}
async function editTemplate(item: EntryTemplateItemDto) {
templateError = "";
try {
const loaded = await loadEntryTemplate(item.filePath);
templateFilePath = loaded.filePath;
templateName = loaded.fileName.replace(/\.template\.md$/i, "");
templateContent = loaded.content;
} catch (error) {
templateError = String(error);
}
}
async function saveTemplate() {
templateError = "";
if (!templateName.trim()) {
templateError = "Template name is required.";
return;
}
if (!templateContent.trim()) {
templateError = "Template content is required.";
return;
}
try {
const wasCreate = !templateFilePath;
const saved = await saveEntryTemplate({
name: templateName,
content: templateContent,
filePath: templateFilePath ?? undefined
});
if (wasCreate) {
resetTemplateEditor();
} else {
templateFilePath = saved.filePath;
}
await refreshTemplates();
} catch (error) {
templateError = String(error);
}
}
async function removeTemplate(item: EntryTemplateItemDto) {
templateError = "";
try {
const ok = await deleteEntryTemplate(item.filePath);
if (!ok) {
templateError = "Failed to delete template.";
return;
}
if (templateFilePath === item.filePath) {
resetTemplateEditor();
}
await refreshTemplates();
} catch (error) {
templateError = String(error);
}
}
</script> </script>
<div class="app-shell panel-closed"> <div class="app-shell panel-closed">
@ -392,57 +300,6 @@
</ul> </ul>
</section> </section>
<section class="route-card">
<h2>Entry Templates</h2>
<p class="section-copy">Create reusable markdown templates stored as <code>.template.md</code> files in your data directory.</p>
<div class="template-grid">
<div class="template-editor">
<input
type="text"
placeholder="Template name (example: Weekly Review)"
bind:value={templateName}
/>
<textarea
rows="8"
placeholder="Template markdown content"
bind:value={templateContent}
></textarea>
<div class="row-actions">
<button type="button" class="secondary-btn" on:click={saveTemplate}>
{templateFilePath ? "Update Template" : "Create Template"}
</button>
<button type="button" class="ghost-btn" on:click={resetTemplateEditor}>New</button>
<button type="button" class="ghost-btn" on:click={refreshTemplates}>Refresh</button>
</div>
</div>
<div class="template-list">
{#if templatesBusy}
<p class="section-copy">Loading templates...</p>
{:else if templates.length === 0}
<p class="section-copy">No templates found.</p>
{:else}
<ul class="item-list">
{#each templates as item}
<li class="item-row">
<span>{item.fileName.replace(/\.template\.md$/i, "")}</span>
<div class="row-actions">
<button type="button" class="ghost-btn" on:click={() => editTemplate(item)}>Edit</button>
<button type="button" class="danger-btn" on:click={() => removeTemplate(item)}>Delete</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if templateError}
<p class="error-text">{templateError}</p>
{/if}
</section>
<section class="route-card"> <section class="route-card">
<h2>Sidecar</h2> <h2>Sidecar</h2>
<p class="section-copy">Root directory containing the Journal.Sidecar project.</p> <p class="section-copy">Root directory containing the Journal.Sidecar project.</p>
@ -625,31 +482,4 @@
color: #e74c3c; color: #e74c3c;
font-size: 0.82rem; font-size: 0.82rem;
} }
.template-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.template-editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.template-editor textarea {
border: 1px solid var(--border-soft);
border-radius: 7px;
background: var(--bg-app);
color: var(--text-primary);
padding: 8px 9px;
resize: vertical;
}
@media (min-width: 980px) {
.template-grid {
grid-template-columns: 1.2fr 1fr;
}
}
</style> </style>