Jinja2 syntax highlighting + description field + preview toggle
Some checks failed
Validate / Hassfest (push) Has been cancelled

JinjaEditor:
- Custom StreamLanguage parser for Jinja2 syntax highlighting:
  {{ variables }} in blue, {% statements %} in purple, {# comments #} in gray
- Replaced HTML mode (didn't understand Jinja2 syntax)
- Proper monospace font (Consolas/Monaco)

TemplateConfig:
- Added `description` field to model + seed defaults with descriptions
- Description shown on template cards instead of raw template text
- Description input in create/edit form

Preview:
- Toggle behavior: clicking Preview again hides the preview
- Per-slot preview uses preview-raw API (renders current editor content)

i18n: added common.description, templateConfig.descriptionPlaceholder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 19:28:00 +03:00
parent 68b104ed40
commit ce21733ae6
15 changed files with 141 additions and 45 deletions

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import MdiIcon from './MdiIcon.svelte';
let { icon, title = '', onclick, disabled = false, variant = 'default', size = 16, class: className = '' } = $props<{
icon: string;
title?: string;
onclick?: (e: MouseEvent) => void;
disabled?: boolean;
variant?: 'default' | 'danger' | 'success';
size?: number;
class?: string;
}>();
const variantClasses = {
default: 'text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] hover:bg-[var(--color-muted)]',
danger: 'text-[var(--color-muted-foreground)] hover:text-[var(--color-destructive)] hover:bg-[var(--color-error-bg)]',
success: 'text-[var(--color-muted-foreground)] hover:text-[var(--color-success-fg)] hover:bg-[var(--color-success-bg)]',
};
</script>
<button type="button" {title} {onclick} {disabled}
class="inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors
disabled:opacity-40 disabled:pointer-events-none {variantClasses[variant]} {className}"
>
<MdiIcon name={icon} {size} />
</button>

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
import { EditorView, placeholder as cmPlaceholder } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { html } from '@codemirror/lang-html';
import { StreamLanguage } from '@codemirror/language';
import { oneDark } from '@codemirror/theme-one-dark';
import { getTheme } from '$lib/theme.svelte';
@@ -17,9 +17,41 @@
let view: EditorView;
const theme = getTheme();
// Simple Jinja2 stream parser for syntax highlighting
const jinjaLang = StreamLanguage.define({
token(stream) {
// Jinja2 comment {# ... #}
if (stream.match('{#')) {
stream.skipTo('#}') && stream.match('#}');
return 'comment';
}
// Jinja2 expression {{ ... }}
if (stream.match('{{')) {
while (!stream.eol()) {
if (stream.match('}}')) return 'variableName';
stream.next();
}
return 'variableName';
}
// Jinja2 statement {% ... %}
if (stream.match('{%')) {
while (!stream.eol()) {
if (stream.match('%}')) return 'keyword';
stream.next();
}
return 'keyword';
}
// Regular text
while (stream.next()) {
if (stream.peek() === '{') break;
}
return null;
},
});
onMount(() => {
const extensions = [
html(), // Jinja2 is close enough to HTML template syntax for highlighting
jinjaLang,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onchange(update.state.doc.toString());
@@ -27,10 +59,14 @@
}),
EditorView.lineWrapping,
EditorView.theme({
'&': { fontSize: '13px', fontFamily: 'monospace' },
'&': { fontSize: '13px', fontFamily: "'Consolas', 'Monaco', 'Courier New', monospace" },
'.cm-content': { minHeight: `${rows * 1.5}em`, padding: '8px' },
'.cm-editor': { borderRadius: '0.375rem', border: '1px solid var(--color-border)' },
'.cm-focused': { outline: '2px solid var(--color-primary)', outlineOffset: '0px' },
// Jinja2 syntax colors
'.ͼc': { color: '#e879f9' }, // keyword ({% %}) - purple
'.ͼd': { color: '#38bdf8' }, // variableName ({{ }}) - blue
'.ͼ5': { color: '#6b7280' }, // comment ({# #}) - gray
}),
];
@@ -50,7 +86,6 @@
return () => view.destroy();
});
// Sync external value changes (e.g. when editing different config)
$effect(() => {
if (view && view.state.doc.toString() !== value) {
view.dispatch({

View File

@@ -237,6 +237,7 @@
"newConfig": "New Config",
"name": "Name",
"namePlaceholder": "Default EN",
"descriptionPlaceholder": "e.g. English templates for family notifications",
"noConfigs": "No template configs yet.",
"eventMessages": "Event Messages",
"assetsAdded": "Assets added",
@@ -296,6 +297,7 @@
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"description": "Description",
"close": "Close",
"confirm": "Confirm",
"error": "Error",

View File

@@ -237,6 +237,7 @@
"newConfig": "Новая конфигурация",
"name": "Название",
"namePlaceholder": "По умолчанию RU",
"descriptionPlaceholder": "напр. Русские шаблоны для семейных уведомлений",
"noConfigs": "Конфигураций шаблонов пока нет.",
"eventMessages": "Сообщения о событиях",
"assetsAdded": "Добавлены файлы",
@@ -296,6 +297,7 @@
"cancel": "Отмена",
"delete": "Удалить",
"edit": "Редактировать",
"description": "Описание",
"close": "Закрыть",
"confirm": "Подтвердить",
"error": "Ошибка",