fix: comprehensive API/UI review — 26 bug fixes and improvements
Backend: - Scheduler lifecycle sync: create/update/delete tracker now syncs APScheduler jobs - Test-periodic/test-memory endpoints render actual Jinja2 templates with sample data - Cascade cleanup on tracker delete (TrackerState removed, EventLog nullified) - Fix user_id=0 FK violation for system-owned TemplateConfig (removed FK constraint) - Fix API key leak: only attach x-api-key header for internal provider URLs - Validate config ownership in tracker_targets create/update - Fix _response() double-emit of created_at in template/tracking configs - Add per-target-link test endpoints (test, test-periodic, test-memory) Frontend: - Fix orphaned provider on test exception in providers/new - Add submitting guard + disabled state to targets save button - Move test buttons from tracker card to per-target-link rows - Fix Svelte 5 async $state reactivity (spread reassignment for all Record mutations) - i18n for dashboard timeAgo and event type badges (EN + RU) - Add required attribute to chat select dropdown in targets - Fix font CSS vars to prioritize imported DM Sans / JetBrains Mono - Standardize empty states with centered icon + text across all 6 list pages - Add stagger-children animation class to all list containers - Fix slide transition duration consistency (200ms everywhere) - Standardize border-radius to rounded-md across all form inputs - Fix providers/new page structure (h2 + mb-8 spacing) - Fix tracker card action row overflow (flex-wrap justify-end) - JinjaEditor dark mode reactivity (recreate editor on theme change) - Add aria-labels to mobile nav items - Make ConfirmModal confirm button label/icon configurable - Remove double error reporting on providers page - Add telegram bot edit functionality (name editing via PUT) - i18n for External Domain label on provider forms Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import Modal from './Modal.svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { open = false, title = '', message = '', confirmLabel = '', confirmIcon = 'mdiDelete', onconfirm, oncancel } = $props<{
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
confirmIcon?: string;
|
||||
onconfirm: () => void;
|
||||
oncancel: () => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<Modal {open} title={title || t('common.confirm')} onclose={oncancel}>
|
||||
<div class="flex items-start gap-3 mb-5">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-full flex-shrink-0"
|
||||
style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiAlertCircle" size={20} />
|
||||
</div>
|
||||
<p class="text-sm mt-1.5" style="color: var(--color-muted-foreground);">{message}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button onclick={oncancel}
|
||||
class="confirm-btn-cancel">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onclick={onconfirm}
|
||||
class="confirm-btn-delete">
|
||||
<MdiIcon name={confirmIcon} size={15} />
|
||||
{confirmLabel || t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.confirm-btn-cancel {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.confirm-btn-cancel:hover {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.confirm-btn-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: var(--color-destructive);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.confirm-btn-delete:hover {
|
||||
box-shadow: 0 0 16px rgba(239, 68, 68, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
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 = '', 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) {
|
||||
if (stream.match('{#')) {
|
||||
stream.skipTo('#}') && stream.match('#}');
|
||||
return 'comment';
|
||||
}
|
||||
if (stream.match('{{')) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match('}}')) return 'variableName';
|
||||
stream.next();
|
||||
}
|
||||
return 'variableName';
|
||||
}
|
||||
if (stream.match('{%')) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match('%}')) return 'keyword';
|
||||
stream.next();
|
||||
}
|
||||
return 'keyword';
|
||||
}
|
||||
while (stream.next()) {
|
||||
if (stream.peek() === '{') break;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const extensions = [
|
||||
jinjaLang,
|
||||
errorLineField,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onchange(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.theme({
|
||||
'&': { 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' },
|
||||
'.cm-error-line': { backgroundColor: 'rgba(239, 68, 68, 0.2)', outline: '1px solid rgba(239, 68, 68, 0.4)' },
|
||||
'.ͼc': { color: '#e879f9' },
|
||||
'.ͼd': { color: '#38bdf8' },
|
||||
'.ͼ5': { color: '#6b7280' },
|
||||
}),
|
||||
];
|
||||
|
||||
if (theme.isDark) {
|
||||
extensions.push(oneDark);
|
||||
}
|
||||
|
||||
if (placeholder) {
|
||||
extensions.push(cmPlaceholder(placeholder));
|
||||
}
|
||||
|
||||
view = new EditorView({
|
||||
state: EditorState.create({ doc: value, extensions }),
|
||||
parent: container,
|
||||
});
|
||||
|
||||
return () => view.destroy();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (view && view.state.doc.toString() !== value) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (view) {
|
||||
view.dispatch({ effects: setErrorLine.of(errorLine ?? null) });
|
||||
}
|
||||
});
|
||||
|
||||
// Recreate editor when theme changes
|
||||
let lastIsDark: boolean | undefined;
|
||||
$effect(() => {
|
||||
const isDark = theme.isDark;
|
||||
if (lastIsDark !== undefined && lastIsDark !== isDark && view) {
|
||||
const currentDoc = view.state.doc.toString();
|
||||
view.destroy();
|
||||
|
||||
const extensions = [
|
||||
jinjaLang,
|
||||
errorLineField,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onchange(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.theme({
|
||||
'&': { 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' },
|
||||
'.cm-error-line': { backgroundColor: 'rgba(239, 68, 68, 0.2)', outline: '1px solid rgba(239, 68, 68, 0.4)' },
|
||||
'.ͼc': { color: '#e879f9' },
|
||||
'.ͼd': { color: '#38bdf8' },
|
||||
'.ͼ5': { color: '#6b7280' },
|
||||
}),
|
||||
];
|
||||
if (isDark) extensions.push(oneDark);
|
||||
if (placeholder) extensions.push(cmPlaceholder(placeholder));
|
||||
|
||||
view = new EditorView({
|
||||
state: EditorState.create({ doc: currentDoc, extensions }),
|
||||
parent: container,
|
||||
});
|
||||
}
|
||||
lastIsDark = isDark;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container}></div>
|
||||
Reference in New Issue
Block a user