d662b50925
Big batch — every secondary page now wears the same glass-card hero that landed on Providers earlier: - notification-trackers, tracking-configs, template-configs - command-trackers, command-configs, command-template-configs - targets (with active-tab title), actions - bots (telegram / email / matrix tabs) - settings, settings/backup, users Each page picks an italic-em emphasis word, an editorial crumb (e.g. 'Routing · Notification', 'Operators · Bots', 'System · Maintenance'), a count meter, and entity-specific status pills derived from live data: 'X armed / Y paused' for trackers and actions, 'X types' for configs/templates, 'X channels' or '$N receivers' for targets. Other changes in this commit: - Button.svelte: redesigned. Primary variant becomes a real Aurora CTA — gradient lavender → orchid pill, 40px tall md / 34px sm, inset highlight, lift + glow on hover. Secondary, danger, ghost variants reworked to match. The 'Add <Type>' button on every page now reads as the page's primary action instead of a flat lavender rectangle. - JinjaEditor: overrode oneDark's hardcoded background with !important so the editor surface picks up var(--color-input-bg). Gutters / scroller / selection / autocomplete tooltip all match Aurora glass tokens now. Template editors stop visually clashing with the surrounding panel. - Aurora pulse dot: rewritten as a self-contained box-shadow glow pulse (no transform, no pseudo-element). The dot's bounding box is now stable so ancestors with overflow:hidden can never clip the visible dot — only the (decorative) outer glow halo. Fixes the 'half-moon clipping' on the dashboard 'On watch' deck. - topbar-action.svelte.ts left in tree but unused (topbar CTA was reverted per your call). Will clean up in a later commit. - Form input baseline styling moved into app.css (rounded 0.625rem, glass background, hover/focus rings) so untouched filter inputs on the per-type pages stop looking out of place. i18n: emphasis / countLabel / armed / paused / receiver / receivers / channelsCount keys added across en + ru. Build clean: 0 errors, 61 pre-existing a11y warnings unchanged.
191 lines
6.1 KiB
Svelte
191 lines
6.1 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { EditorView, Decoration, keymap, placeholder as cmPlaceholder, type DecorationSet } from '@codemirror/view';
|
|
import { EditorState, StateField, StateEffect, Compartment } from '@codemirror/state';
|
|
import { StreamLanguage } from '@codemirror/language';
|
|
import { oneDark } from '@codemirror/theme-one-dark';
|
|
import { acceptCompletion } from '@codemirror/autocomplete';
|
|
import { getTheme } from '$lib/theme.svelte';
|
|
import { jinjaAutocomplete, type SlotVariables } from '$lib/editor/jinja-autocomplete';
|
|
|
|
let { value = '', onchange, rows = 6, placeholder = '', errorLine = null, variables = undefined } = $props<{
|
|
value: string;
|
|
onchange: (val: string) => void;
|
|
rows?: number;
|
|
placeholder?: string;
|
|
errorLine?: number | null;
|
|
variables?: SlotVariables;
|
|
}>();
|
|
|
|
const autocompleteCompartment = new Compartment();
|
|
|
|
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;
|
|
},
|
|
});
|
|
|
|
function buildExtensions(isDark: boolean) {
|
|
const extensions = [
|
|
jinjaLang,
|
|
errorLineField,
|
|
autocompleteCompartment.of(variables ? jinjaAutocomplete(variables) : []),
|
|
keymap.of([{ key: 'Tab', run: acceptCompletion }]),
|
|
EditorView.updateListener.of((update) => {
|
|
if (update.docChanged) {
|
|
onchange(update.state.doc.toString());
|
|
}
|
|
}),
|
|
EditorView.lineWrapping,
|
|
];
|
|
// Apply oneDark first so its syntax-token colors are kept,
|
|
// then override with our Aurora-aware theme so background,
|
|
// borders, and gutters match the rest of the design.
|
|
if (isDark) extensions.push(oneDark);
|
|
extensions.push(EditorView.theme({
|
|
'&': {
|
|
fontSize: '13px',
|
|
fontFamily: 'var(--font-mono)',
|
|
backgroundColor: 'var(--color-input-bg) !important',
|
|
borderRadius: '14px',
|
|
border: '1px solid var(--color-rule-strong)',
|
|
color: 'var(--color-foreground)',
|
|
overflow: 'hidden',
|
|
},
|
|
'.cm-editor': { backgroundColor: 'transparent !important', borderRadius: '14px' },
|
|
'.cm-scroller': { backgroundColor: 'transparent !important' },
|
|
'.cm-content': { minHeight: `${rows * 1.5}em`, padding: '12px 14px', caretColor: 'var(--color-primary)' },
|
|
'.cm-gutters': {
|
|
backgroundColor: 'transparent',
|
|
color: 'var(--color-muted-foreground)',
|
|
borderRight: '1px solid var(--color-border)',
|
|
},
|
|
'.cm-activeLineGutter': { backgroundColor: 'var(--color-glass-strong)' },
|
|
'.cm-activeLine': { backgroundColor: 'var(--color-glass-strong)' },
|
|
'.cm-cursor': { borderLeftColor: 'var(--color-primary)' },
|
|
'.cm-selectionBackground, ::selection': { backgroundColor: 'var(--color-glass-elev) !important' },
|
|
'&.cm-focused .cm-selectionBackground': { backgroundColor: 'var(--color-glow) !important' },
|
|
'.cm-focused': { outline: 'none' },
|
|
'&.cm-focused': { borderColor: 'var(--color-primary)', boxShadow: '0 0 0 3px var(--color-glow)' },
|
|
'.cm-error-line': { backgroundColor: 'rgba(255, 138, 120, 0.18)', outline: '1px solid rgba(255, 138, 120, 0.4)' },
|
|
'.ͼc': { color: 'var(--color-orchid)' },
|
|
'.ͼd': { color: 'var(--color-sky)' },
|
|
'.ͼ5': { color: 'var(--color-muted-foreground)' },
|
|
'.cm-tooltip-autocomplete': {
|
|
background: 'color-mix(in srgb, var(--color-background) 92%, transparent)',
|
|
backdropFilter: 'blur(28px) saturate(160%)',
|
|
border: '1px solid var(--color-rule-strong)',
|
|
borderRadius: '12px',
|
|
fontSize: '12px',
|
|
boxShadow: '0 12px 30px -12px rgba(0,0,0,0.4)',
|
|
overflow: 'hidden',
|
|
},
|
|
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
|
|
backgroundColor: 'var(--color-glass-elev)',
|
|
color: 'var(--color-primary)',
|
|
},
|
|
}));
|
|
if (placeholder) extensions.push(cmPlaceholder(placeholder));
|
|
return extensions;
|
|
}
|
|
|
|
onMount(() => {
|
|
view = new EditorView({
|
|
state: EditorState.create({ doc: value, extensions: buildExtensions(theme.isDark) }),
|
|
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) });
|
|
}
|
|
});
|
|
|
|
// Update autocomplete when variables change
|
|
$effect(() => {
|
|
if (view) {
|
|
view.dispatch({
|
|
effects: autocompleteCompartment.reconfigure(
|
|
variables ? jinjaAutocomplete(variables) : [],
|
|
),
|
|
});
|
|
}
|
|
});
|
|
|
|
// 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();
|
|
|
|
view = new EditorView({
|
|
state: EditorState.create({ doc: currentDoc, extensions: buildExtensions(isDark) }),
|
|
parent: container,
|
|
});
|
|
}
|
|
lastIsDark = isDark;
|
|
});
|
|
</script>
|
|
|
|
<div bind:this={container}></div>
|