feat: IconGridSelect, CrossLink, SearchPalette components + entity crosslinks

New components:
- IconGridSelect: Visual grid selector replacing <select> dropdowns,
  with icon + label cells, fixed-position popup, smart placement
- CrossLink: Inline clickable badge for cross-entity navigation,
  hover highlights primary, used on entity cards
- SearchPalette: Ctrl+K global command palette, searches all entity
  types via cached data, grouped results, keyboard navigation

Integration:
- Targets: type selector uses IconGridSelect (4-column grid with icons)
- Targets: bot crosslink on telegram/email/matrix target cards
- Command Trackers: provider and config badges → CrossLinks
- Command Trackers: listener bot name → CrossLink
- Command Configs: template config shown as CrossLink on card
- Notification Trackers: provider CrossLink added to card
- Layout: SearchPalette mounted globally

Infrastructure:
- Added CommandConfig, CommandTemplateConfig, CommandTracker types
- Added notificationTrackersCache, commandTrackersCache to caches
- Added allCaches map and fetchAllCaches() for search palette
- Added searchPalette i18n keys (EN/RU)
This commit is contained in:
2026-03-21 23:44:12 +03:00
parent 563716fa76
commit 06b24638cb
12 changed files with 764 additions and 26 deletions
+2
View File
@@ -11,6 +11,7 @@
import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Snackbar from '$lib/components/Snackbar.svelte';
import SearchPalette from '$lib/components/SearchPalette.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
let { children } = $props();
@@ -440,6 +441,7 @@
</Modal>
<Snackbar />
<SearchPalette />
<style>
@media (max-width: 767px) {
@@ -11,8 +11,15 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
function templateName(id: number | null): string {
if (!id) return '';
const tpl = cmdTemplateConfigs.find((c: any) => c.id === id);
return tpl?.name || `#${id}`;
}
let configs = $derived(commandConfigsCache.items);
let cmdTemplateConfigs = $derived(commandTemplateConfigsCache.items);
let loaded = $state(false);
@@ -243,10 +250,15 @@
</span>
<span class="text-xs text-[var(--color-muted-foreground)]">{cfg.locale?.toUpperCase()}</span>
</div>
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
{t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
&middot; {t('commandConfig.defaultCount')}: {cfg.default_count}
</p>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-xs text-[var(--color-muted-foreground)]">
{t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
&middot; {t('commandConfig.defaultCount')}: {cfg.default_count}
</span>
{#if cfg.command_template_config_id}
<CrossLink href="/command-template-configs" icon="mdiCodeBracesBox" label={templateName(cfg.command_template_config_id)} />
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editConfig(cfg)} />
@@ -12,6 +12,7 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import type { ServiceProvider, TelegramBot } from '$lib/types';
@@ -231,8 +232,8 @@
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{trk.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{providerName(trk.provider_id)}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{configName(trk.command_config_id)}</span>
<CrossLink href="/providers" icon="mdiServer" label={providerName(trk.provider_id)} />
<CrossLink href="/command-configs" icon="mdiCog" label={configName(trk.command_config_id)} />
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
? 'bg-emerald-500/10 text-emerald-500'
: 'bg-red-500/10 text-red-500'}">
@@ -269,7 +270,7 @@
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
<div class="flex items-center gap-2">
<MdiIcon name="mdiRobot" size={14} />
<span class="font-medium">{listener.name || listener.listener_type}</span>
<CrossLink href="/telegram-bots" icon="mdiRobot" label={listener.name || listener.listener_type} />
<span class="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-500 font-mono">{listener.listener_type}</span>
</div>
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
@@ -14,6 +14,7 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import Hint from '$lib/components/Hint.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import type { Tracker, ServiceProvider, NotificationTarget, TrackingConfig, TemplateConfig } from '$lib/types';
@@ -234,6 +235,11 @@
return p?.type || '';
}
function getProviderName(id: number): string {
const p = providers.find(p => p.id === id);
return p?.name || `#${id}`;
}
function configsForTracker(tracker: any, configs: (TrackingConfig | TemplateConfig)[]): any[] {
const pt = getProviderType(tracker);
return pt ? configs.filter((c: any) => c.provider_type === pt) : configs;
@@ -373,6 +379,7 @@
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')}
</span>
t <CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} />
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{(tracker.collection_ids || []).length} {t('notificationTracker.albums_count')} · {t('notificationTracker.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
+34 -7
View File
@@ -14,9 +14,34 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import Hint from '$lib/components/Hint.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import type { NotificationTarget, TelegramBot, TelegramChat, EmailBot, MatrixBot } from '$lib/types';
function getBotName(target: any): string | null {
if (target.type === 'telegram' && target.config?.bot_id) {
const bot = telegramBots.find(b => b.id === target.config.bot_id);
return bot?.name || null;
}
if (target.type === 'email' && target.config?.email_bot_id) {
const bot = emailBots.find(b => b.id === target.config.email_bot_id);
return bot?.name || null;
}
if (target.type === 'matrix' && target.config?.matrix_bot_id) {
const bot = matrixBots.find(b => b.id === target.config.matrix_bot_id);
return bot?.name || null;
}
return null;
}
function getBotHref(target: any): string {
if (target.type === 'telegram') return '/telegram-bots';
if (target.type === 'email') return '/telegram-bots?tab=email';
if (target.type === 'matrix') return '/telegram-bots?tab=matrix';
return '/telegram-bots';
}
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix'] as const;
type TargetType = typeof ALL_TYPES[number];
const TYPE_ICONS: Record<string, string> = {
@@ -28,6 +53,12 @@
discord: 'targets.descDiscord', slack: 'targets.descSlack', ntfy: 'targets.descNtfy', matrix: 'targets.descMatrix',
};
const typeGridItems = $derived(ALL_TYPES.map(tt => ({
value: tt,
icon: TYPE_ICONS[tt] || 'mdiTarget',
label: tt.charAt(0).toUpperCase() + tt.slice(1),
})));
let allTargets = $derived(targetsCache.items);
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
let targets = $derived(activeType ? allTargets.filter(t => t.type === activeType) : allTargets);
@@ -189,13 +220,8 @@
<form onsubmit={save} class="space-y-4">
{#if !activeType}
<div>
<label for="tgt-type" class="block text-sm font-medium mb-1">{t('targets.type')}</label>
<select id="tgt-type" bind:value={formType}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
{#each ALL_TYPES as tt}
<option value={tt}>{tt.charAt(0).toUpperCase() + tt.slice(1)}</option>
{/each}
</select>
<label class="block text-sm font-medium mb-1">{t('targets.type')}</label>
<IconGridSelect items={typeGridItems} bind:value={formType} columns={4} />
</div>
{/if}
<div>
@@ -380,6 +406,7 @@
<p class="font-medium">{target.name}</p>
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
{#if target.receiver_count}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.receiver_count} receiver(s)</span>{/if}
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target)} />{/if}
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{#if target.type === 'telegram'}