fix: UI polish — overflow, placeholders, dashboard provider card

- Fix bot card header overflow by replacing "Sync with Telegram" text
  button with icon button, add flex-wrap
- Rename sync button label to "Sync Commands"
- Remove decorative dashes from selector placeholders (— X — → X)
- Show selected provider name/icon in dashboard stat card when global
  provider filter is active
- Add selector placeholder convention to frontend-architecture.md
This commit is contained in:
2026-03-23 21:26:49 +03:00
parent 1cfa72888c
commit 4049efe186
7 changed files with 38 additions and 27 deletions
+6
View File
@@ -42,3 +42,9 @@ Shared entities use a `$state`-based cache layer in `frontend/src/lib/stores/`:
2. Add `export const fooCache = createEntityCache<Foo>('/foo');` to `caches.svelte.ts`
3. Add `fooCache.clear()` to `clearAllCaches()`
4. In page components: replace `let foo = $state<Foo[]>([])` with `let foo = $derived(fooCache.items)` and replace `api('/foo')` with `fooCache.fetch()`
## UI Conventions
### Selector Placeholders
**IMPORTANT**: Selector/dropdown placeholder text must be plain and simple — no decorative dashes or special characters. Use `Select provider...` or `Tracking Configs`, NOT `— Select provider —` or `-- Tracking Configs --`. The i18n keys already follow this convention; never wrap them with `'— ' + t('key') + ' —'` in Svelte templates.
+3 -2
View File
@@ -16,8 +16,9 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the
2. **Overlays** MUST use `position: fixed` with inline styles and `z-index: 9999` — see [frontend-architecture.md](.claude/docs/frontend-architecture.md).
3. **Template variables** must be updated in 6 files simultaneously — see [template-system.md](.claude/docs/template-system.md).
4. **Entity cache** — shared entities use `$state`-based caches in `frontend/src/lib/stores/caches.svelte.ts`. Always use cache for cross-page data; invalidate after mutations — see [frontend-architecture.md](.claude/docs/frontend-architecture.md).
5. **Telegram API** — ALL Telegram Bot API calls (sendMessage, sendPhoto, sendMediaGroup, etc.) MUST go through `TelegramClient` in `packages/core/src/notify_bridge_core/notifications/telegram/client.py`. NEVER duplicate sending logic in command handlers, API routes, or services. If `TelegramClient` lacks a method you need, add it there.
6. **Service provider defaults** — when implementing a new service provider, ALWAYS create default notification and command templates and configs. This requires changes across all of these locations:
5. **Selector placeholders** — use plain text without decorative dashes. `Select provider...` not `— Select provider —` — see [frontend-architecture.md](.claude/docs/frontend-architecture.md).
6. **Telegram API** — ALL Telegram Bot API calls (sendMessage, sendPhoto, sendMediaGroup, etc.) MUST go through `TelegramClient` in `packages/core/src/notify_bridge_core/notifications/telegram/client.py`. NEVER duplicate sending logic in command handlers, API routes, or services. If `TelegramClient` lacks a method you need, add it there.
7. **Service provider defaults** — when implementing a new service provider, ALWAYS create default notification and command templates and configs. This requires changes across all of these locations:
- Jinja2 notification templates for each locale in `packages/core/src/notify_bridge_core/templates/defaults/{en,ru}/`
- Jinja2 command templates for each locale in `packages/core/src/notify_bridge_core/templates/command_defaults/{en,ru}/{provider}/`
- Notification slot mapping in `packages/core/src/notify_bridge_core/templates/defaults/loader.py` (`PROVIDER_SLOT_FILE_MAP`)
+2 -2
View File
@@ -330,7 +330,7 @@
"rateFind": "Find cooldown",
"rateDefault": "Default cooldown",
"noCommandsForProvider": "This provider type does not support bot commands.",
"syncCommands": "Sync with Telegram",
"syncCommands": "Sync Commands",
"discoverChats": "Discover chats from Telegram",
"clickToCopy": "Click to copy chat ID",
"chatsDiscovered": "Chats discovered",
@@ -873,7 +873,7 @@
"favoritesOnly": "Favorites only",
"targetAlbum": "Target Album",
"selectAlbum": "Album",
"selectAlbumPlaceholder": "Select album",
"selectAlbumPlaceholder": "Select album",
"albumId": "Album ID",
"createAlbumIfMissing": "Create album if it doesn't exist",
"newAlbumName": "New album name",
+2 -2
View File
@@ -330,7 +330,7 @@
"rateFind": "Кулдаун поиска файлов",
"rateDefault": "Кулдаун по умолчанию",
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
"syncCommands": "Синхронизировать с Telegram",
"syncCommands": "Синхр. команды",
"discoverChats": "Обнаружить чаты из Telegram",
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
"chatsDiscovered": "Чаты обнаружены",
@@ -873,7 +873,7 @@
"favoritesOnly": "Только избранное",
"targetAlbum": "Целевой альбом",
"selectAlbum": "Альбом",
"selectAlbumPlaceholder": "Выберите альбом",
"selectAlbumPlaceholder": "Выберите альбом",
"albumId": "ID альбома",
"createAlbumIfMissing": "Создать альбом, если не существует",
"newAlbumName": "Название нового альбома",
+13 -5
View File
@@ -177,8 +177,12 @@
let displayCommandTrackers = $state(0);
const filteredProvider = $derived(globalProviderFilter.id ? providers.find(p => p.id === globalProviderFilter.id) : null);
const statCards = $derived(status ? [
{ icon: 'mdiServer', label: 'dashboard.providers', value: displayProviders, color: '#0d9488' },
filteredProvider
? { icon: providerDefaultIcon(filteredProvider), label: filteredProvider.name, value: filteredProvider.type, color: '#0d9488', isProvider: true }
: { icon: 'mdiServer', label: 'dashboard.providers', value: displayProviders, color: '#0d9488' },
{ icon: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' },
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
...(status.command_trackers !== undefined ? [{ icon: 'mdiConsoleLine', label: 'nav.commandTrackers', value: displayCommandTrackers, color: '#8b5cf6' }] : []),
@@ -234,10 +238,14 @@
<MdiIcon name={card.icon} size={22} />
</div>
<div>
<p class="text-sm" style="color: var(--color-muted-foreground);">{t(card.label)}</p>
<p class="stat-value font-mono" style="animation-delay: {i * 80 + 200}ms;">
{card.value}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
</p>
<p class="text-sm" style="color: var(--color-muted-foreground);">{card.isProvider ? card.label : t(card.label)}</p>
{#if card.isProvider}
<p class="text-lg font-medium" style="color: {card.color};">{card.value}</p>
{:else}
<p class="stat-value font-mono" style="animation-delay: {i * 80 + 200}ms;">
{card.value}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
</p>
{/if}
</div>
</div>
</div>
+7 -11
View File
@@ -318,9 +318,9 @@
<div class="space-y-3 stagger-children">
{#each bots as bot}
<Card hover entityId={bot.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
{#if bot.bot_username}
@@ -335,21 +335,17 @@
</div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
</div>
<div class="flex items-center gap-1">
<div class="flex items-center gap-1 flex-shrink-0 flex-wrap">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
<button onclick={() => toggleSection(bot.id, 'chats')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap">
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
</button>
<button onclick={() => { toggleSection(bot.id, 'listeners'); if (expandedSection[bot.id] === 'listeners') loadListenerStatus(bot.id); }}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap">
{t('commandTracker.listeners')} {expandedSection[bot.id] === 'listeners' ? '▲' : '▼'}
</button>
<button onclick={() => syncCommands(bot.id)} disabled={modeChanging[bot.id]}
class="text-xs text-[var(--color-primary)] hover:underline px-2 py-1 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.syncCommands')}
</button>
<IconButton icon="mdiSync" title={t('telegramBot.syncCommands')} onclick={() => syncCommands(bot.id)} disabled={modeChanging[bot.id]} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
</div>
</div>
@@ -85,12 +85,12 @@
<div class="flex items-center gap-2 flex-wrap justify-end">
<div class="min-w-[140px]">
<EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
placeholder={'— ' + t('trackingConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('trackingConfig.title') + ' —'}
placeholder={t('trackingConfig.title')} size="sm" allowNone noneLabel={t('trackingConfig.title')}
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
</div>
<div class="min-w-[140px]">
<EntitySelect items={templateConfigItems} value={tt.template_config_id}
placeholder={'— ' + t('templateConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('templateConfig.title') + ' —'}
placeholder={t('templateConfig.title')} size="sm" allowNone noneLabel={t('templateConfig.title')}
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
</div>
<div class="relative">
@@ -113,17 +113,17 @@
<div class="flex items-center gap-2 mt-2">
<div class="flex-1 min-w-[140px]">
<EntitySelect items={targetItems} value={newLinkTargetId || null}
placeholder={'— ' + t('notificationTracker.addTarget') + ' —'} size="sm"
placeholder={t('notificationTracker.addTarget')} size="sm"
onselect={(v) => onchangeNewTarget(Number(v) || 0)} />
</div>
<div class="min-w-[140px]">
<EntitySelect items={trackingConfigItems} value={newLinkTrackingConfigId || null}
placeholder={'— ' + t('trackingConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('trackingConfig.title') + ' —'}
placeholder={t('trackingConfig.title')} size="sm" allowNone noneLabel={t('trackingConfig.title')}
onselect={(v) => onchangeNewTrackingConfig(Number(v) || 0)} />
</div>
<div class="min-w-[140px]">
<EntitySelect items={templateConfigItems} value={newLinkTemplateConfigId || null}
placeholder={'— ' + t('templateConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('templateConfig.title') + ' —'}
placeholder={t('templateConfig.title')} size="sm" allowNone noneLabel={t('templateConfig.title')}
onselect={(v) => onchangeNewTemplateConfig(Number(v) || 0)} />
</div>
<button onclick={onaddLink}