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": "строка"
}
}