Jinja2 syntax highlighting + description field + preview toggle
All checks were successful
Validate / Hassfest (push) Successful in 32s

- Error line highlighting in JinjaEditor (red background on error line)
- Backend returns error_line from TemplateSyntaxError
- Localized syntax error messages with line number
- Renamed {{ }} button to "Variables" (localized)
- Localized all template variable descriptions (EN/RU)
- Added t() fallback parameter for graceful degradation
- Page transition animation (fade) to prevent content stacking
- Added syntaxError/line i18n keys

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 20:44:57 +03:00
parent 31873a8ffd
commit 59108a834c
7 changed files with 154 additions and 20 deletions

View File

@@ -1,22 +1,44 @@
<script lang="ts">
import { onMount } from 'svelte';
import { EditorView, placeholder as cmPlaceholder } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { EditorView, Decoration, placeholder as cmPlaceholder, type DecorationSet } from '@codemirror/view';
import { EditorState, StateField, StateEffect } from '@codemirror/state';
import { StreamLanguage } from '@codemirror/language';
import { oneDark } from '@codemirror/theme-one-dark';
import { getTheme } from '$lib/theme.svelte';
let { value = '', onchange, rows = 6, placeholder = '' } = $props<{
let { value = '', onchange, rows = 6, placeholder = '', errorLine = null } = $props<{
value: string;
onchange: (val: string) => void;
rows?: number;
placeholder?: string;
errorLine?: number | null;
}>();
let container: HTMLDivElement;
let view: EditorView;
const theme = getTheme();
// Error line highlight effect and field
const setErrorLine = StateEffect.define<number | null>();
const errorLineField = StateField.define<DecorationSet>({
create() { return Decoration.none; },
update(decorations, tr) {
for (const e of tr.effects) {
if (e.is(setErrorLine)) {
if (e.value === null) return Decoration.none;
const lineNum = e.value;
if (lineNum < 1 || lineNum > tr.state.doc.lines) return Decoration.none;
const line = tr.state.doc.line(lineNum);
return Decoration.set([
Decoration.line({ class: 'cm-error-line' }).range(line.from),
]);
}
}
return decorations;
},
provide: f => EditorView.decorations.from(f),
});
// Simple Jinja2 stream parser for syntax highlighting
const jinjaLang = StreamLanguage.define({
token(stream) {
@@ -52,6 +74,7 @@
onMount(() => {
const extensions = [
jinjaLang,
errorLineField,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onchange(update.state.doc.toString());
@@ -63,6 +86,7 @@
'.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' },
'.cm-error-line': { backgroundColor: 'rgba(239, 68, 68, 0.2)', outline: '1px solid rgba(239, 68, 68, 0.4)' },
// Jinja2 syntax colors
'.ͼc': { color: '#e879f9' }, // keyword ({% %}) - purple
'.ͼd': { color: '#38bdf8' }, // variableName ({{ }}) - blue
@@ -93,6 +117,12 @@
});
}
});
$effect(() => {
if (view) {
view.dispatch({ effects: setErrorLine.of(errorLine ?? null) });
}
});
</script>
<div bind:this={container}></div>

View File

@@ -266,8 +266,48 @@
"telegramSettings": "Telegram",
"videoWarning": "Video warning",
"preview": "Preview",
"variables": "Variables",
"assetFields": "Asset fields (in {% for asset in added_assets %})",
"confirmDelete": "Delete this template config?"
},
"templateVars": {
"message_assets_added": { "description": "Notification when new assets are added to an album" },
"message_assets_removed": { "description": "Notification when assets are removed from an album" },
"message_album_renamed": { "description": "Notification when an album is renamed" },
"message_album_deleted": { "description": "Notification when an album is deleted" },
"periodic_summary_message": { "description": "Periodic album summary with stats" },
"scheduled_assets_message": { "description": "Scheduled asset picks from albums" },
"memory_mode_message": { "description": "\"On This Day\" memories from past years" },
"album_name": "Album name",
"album_url": "Public share URL (if available)",
"added_count": "Number of assets added",
"removed_count": "Number of assets removed",
"change_type": "Type of change",
"people": "Detected people names (use {{ people | join(', ') }})",
"added_assets": "List of added asset objects (use {% for asset in added_assets %})",
"removed_assets": "List of removed asset IDs",
"shared": "Whether album is shared (true/false)",
"video_warning": "Video size warning text",
"old_name": "Previous album name",
"new_name": "New album name",
"albums": "List of album objects (use {% for album in albums %})",
"assets": "List of asset objects (use {% for asset in assets %})",
"date": "Current date/time",
"asset_filename": "Original filename",
"asset_type": "IMAGE or VIDEO",
"asset_created_at": "Creation date/time (ISO 8601)",
"asset_owner": "Owner display name",
"asset_description": "User or EXIF description",
"asset_url": "Public viewer URL",
"asset_download_url": "Direct download URL",
"asset_photo_url": "Preview image URL (images only)",
"asset_playback_url": "Video playback URL (videos only)",
"asset_is_favorite": "Whether asset is favorited",
"asset_rating": "Star rating (1-5 or null)",
"asset_city": "City name",
"asset_state": "State/region name",
"asset_country": "Country name"
},
"hints": {
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
"scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.",
@@ -318,6 +358,8 @@
"newPassword": "New password",
"passwordChanged": "Password changed successfully",
"expand": "Expand",
"collapse": "Collapse"
"collapse": "Collapse",
"syntaxError": "Syntax error",
"line": "line"
}
}

View File

@@ -45,9 +45,10 @@ export function initLocale() {
* Falls back to English if key not found in current locale.
* Reactive: re-evaluates when currentLocale changes.
*/
export function t(key: string): string {
export function t(key: string, fallback?: string): string {
return resolve(translations[currentLocale], key)
?? resolve(translations.en, key)
?? fallback
?? key;
}

View File

@@ -266,8 +266,48 @@
"telegramSettings": "Telegram",
"videoWarning": "Предупреждение о видео",
"preview": "Предпросмотр",
"variables": "Переменные",
"assetFields": "Поля файла (в {% for asset in added_assets %})",
"confirmDelete": "Удалить эту конфигурацию шаблона?"
},
"templateVars": {
"message_assets_added": { "description": "Уведомление о добавлении файлов в альбом" },
"message_assets_removed": { "description": "Уведомление об удалении файлов из альбома" },
"message_album_renamed": { "description": "Уведомление о переименовании альбома" },
"message_album_deleted": { "description": "Уведомление об удалении альбома" },
"periodic_summary_message": { "description": "Периодическая сводка альбомов со статистикой" },
"scheduled_assets_message": { "description": "Запланированная подборка фото из альбомов" },
"memory_mode_message": { "description": "«В этот день» — фото из прошлых лет" },
"album_name": "Название альбома",
"album_url": "Публичная ссылка (если есть)",
"added_count": "Количество добавленных файлов",
"removed_count": "Количество удалённых файлов",
"change_type": "Тип изменения",
"people": "Обнаруженные люди ({{ people | join(', ') }})",
"added_assets": "Список добавленных файлов ({% for asset in added_assets %})",
"removed_assets": "Список ID удалённых файлов",
"shared": "Общий альбом (true/false)",
"video_warning": "Предупреждение о размере видео",
"old_name": "Прежнее название альбома",
"new_name": "Новое название альбома",
"albums": "Список альбомов ({% for album in albums %})",
"assets": "Список файлов ({% for asset in assets %})",
"date": "Текущая дата/время",
"asset_filename": "Имя файла",
"asset_type": "IMAGE или VIDEO",
"asset_created_at": "Дата создания (ISO 8601)",
"asset_owner": "Имя владельца",
"asset_description": "Описание (EXIF или пользовательское)",
"asset_url": "Ссылка для просмотра",
"asset_download_url": "Ссылка для скачивания",
"asset_photo_url": "URL превью (только фото)",
"asset_playback_url": "URL видео (только видео)",
"asset_is_favorite": "В избранном",
"asset_rating": "Рейтинг (1-5 или null)",
"asset_city": "Город",
"asset_state": "Регион",
"asset_country": "Страна"
},
"hints": {
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
@@ -318,6 +358,8 @@
"newPassword": "Новый пароль",
"passwordChanged": "Пароль успешно изменён",
"expand": "Развернуть",
"collapse": "Свернуть"
"collapse": "Свернуть",
"syntaxError": "Ошибка синтаксиса",
"line": "строка"
}
}

View File

@@ -3,6 +3,7 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { api } from '$lib/api';
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
@@ -196,9 +197,11 @@
<!-- Main content -->
<main class="flex-1 overflow-auto pb-16 md:pb-0">
<div class="max-w-5xl mx-auto p-4 md:p-6">
{#key page.url.pathname}
<div class="max-w-5xl mx-auto p-4 md:p-6" in:fade={{ duration: 150, delay: 50 }}>
{@render children()}
</div>
{/key}
</main>
</div>
{:else}

View File

@@ -24,21 +24,28 @@
let confirmDelete = $state<any>(null);
let slotPreview = $state<Record<string, string>>({});
let slotErrors = $state<Record<string, string>>({});
let slotErrorLines = $state<Record<string, number | null>>({});
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
function validateSlot(slotKey: string, template: string) {
// Clear previous timer
if (validateTimers[slotKey]) clearTimeout(validateTimers[slotKey]);
if (!template) { slotErrors = { ...slotErrors, [slotKey]: '' }; return; }
if (!template) {
slotErrors = { ...slotErrors, [slotKey]: '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
return;
}
// Debounce 800ms
validateTimers[slotKey] = setTimeout(async () => {
try {
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template }) });
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
} catch {
// Network error, don't show as template error
slotErrors = { ...slotErrors, [slotKey]: '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
}
}, 800);
}
@@ -175,14 +182,14 @@
{/if}
{#if varsRef[slot.key]}
<button type="button" onclick={() => showVarsFor = slot.key}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{'{{ }}'}</button>
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
</div>
</div>
{#if (slot.rows || 2) > 2}
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 6} />
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 6} errorLine={slotErrorLines[slot.key] || null} />
{#if slotErrors[slot.key]}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">Syntax error: {slotErrors[slot.key]}</p>
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
{/if}
{#if slotPreview[slot.key]}
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">
@@ -239,25 +246,25 @@
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="Template Variables: {showVarsFor}" onclose={() => showVarsFor = null}>
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]}
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{varsRef[showVarsFor].description}</p>
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{t(`templateVars.${showVarsFor}.description`, varsRef[showVarsFor].description)}</p>
<div class="space-y-1">
<p class="text-xs font-medium mb-1">Variables:</p>
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.${name}`, desc as string)}</span>
</div>
{/each}
</div>
{#if varsRef[showVarsFor].asset_fields}
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<p class="text-xs font-medium mb-1">Asset fields (in {'{'}% for asset in added_assets %{'}'}):</p>
<p class="text-xs font-medium mb-1">{t('templateConfig.assetFields')}:</p>
{#each Object.entries(varsRef[showVarsFor].asset_fields || {}) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ asset.' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.asset_${name}`, desc as string)}</span>
</div>
{/each}
</div>

View File

@@ -6,6 +6,7 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from jinja2.sandbox import SandboxedEnvironment
from jinja2 import TemplateSyntaxError, UndefinedError
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
@@ -172,8 +173,16 @@ async def preview_raw(
tmpl = env.from_string(body.template)
rendered = tmpl.render(**_SAMPLE_CONTEXT)
return {"rendered": rendered}
except TemplateSyntaxError as e:
return {
"rendered": None,
"error": e.message,
"error_line": e.lineno,
}
except UndefinedError as e:
return {"rendered": None, "error": str(e), "error_line": None}
except Exception as e:
return {"rendered": None, "error": str(e)}
return {"rendered": None, "error": str(e), "error_line": None}
def _response(c: TemplateConfig) -> dict: