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:
@@ -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`
|
2. Add `export const fooCache = createEntityCache<Foo>('/foo');` to `caches.svelte.ts`
|
||||||
3. Add `fooCache.clear()` to `clearAllCaches()`
|
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()`
|
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.
|
||||||
|
|||||||
@@ -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).
|
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).
|
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).
|
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.
|
5. **Selector placeholders** — use plain text without decorative dashes. `Select provider...` not `— Select provider —` — see [frontend-architecture.md](.claude/docs/frontend-architecture.md).
|
||||||
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:
|
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 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}/`
|
- 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`)
|
- Notification slot mapping in `packages/core/src/notify_bridge_core/templates/defaults/loader.py` (`PROVIDER_SLOT_FILE_MAP`)
|
||||||
|
|||||||
@@ -330,7 +330,7 @@
|
|||||||
"rateFind": "Find cooldown",
|
"rateFind": "Find cooldown",
|
||||||
"rateDefault": "Default cooldown",
|
"rateDefault": "Default cooldown",
|
||||||
"noCommandsForProvider": "This provider type does not support bot commands.",
|
"noCommandsForProvider": "This provider type does not support bot commands.",
|
||||||
"syncCommands": "Sync with Telegram",
|
"syncCommands": "Sync Commands",
|
||||||
"discoverChats": "Discover chats from Telegram",
|
"discoverChats": "Discover chats from Telegram",
|
||||||
"clickToCopy": "Click to copy chat ID",
|
"clickToCopy": "Click to copy chat ID",
|
||||||
"chatsDiscovered": "Chats discovered",
|
"chatsDiscovered": "Chats discovered",
|
||||||
@@ -873,7 +873,7 @@
|
|||||||
"favoritesOnly": "Favorites only",
|
"favoritesOnly": "Favorites only",
|
||||||
"targetAlbum": "Target Album",
|
"targetAlbum": "Target Album",
|
||||||
"selectAlbum": "Album",
|
"selectAlbum": "Album",
|
||||||
"selectAlbumPlaceholder": "— Select album —",
|
"selectAlbumPlaceholder": "Select album",
|
||||||
"albumId": "Album ID",
|
"albumId": "Album ID",
|
||||||
"createAlbumIfMissing": "Create album if it doesn't exist",
|
"createAlbumIfMissing": "Create album if it doesn't exist",
|
||||||
"newAlbumName": "New album name",
|
"newAlbumName": "New album name",
|
||||||
|
|||||||
@@ -330,7 +330,7 @@
|
|||||||
"rateFind": "Кулдаун поиска файлов",
|
"rateFind": "Кулдаун поиска файлов",
|
||||||
"rateDefault": "Кулдаун по умолчанию",
|
"rateDefault": "Кулдаун по умолчанию",
|
||||||
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
|
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
|
||||||
"syncCommands": "Синхронизировать с Telegram",
|
"syncCommands": "Синхр. команды",
|
||||||
"discoverChats": "Обнаружить чаты из Telegram",
|
"discoverChats": "Обнаружить чаты из Telegram",
|
||||||
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
|
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
|
||||||
"chatsDiscovered": "Чаты обнаружены",
|
"chatsDiscovered": "Чаты обнаружены",
|
||||||
@@ -873,7 +873,7 @@
|
|||||||
"favoritesOnly": "Только избранное",
|
"favoritesOnly": "Только избранное",
|
||||||
"targetAlbum": "Целевой альбом",
|
"targetAlbum": "Целевой альбом",
|
||||||
"selectAlbum": "Альбом",
|
"selectAlbum": "Альбом",
|
||||||
"selectAlbumPlaceholder": "— Выберите альбом —",
|
"selectAlbumPlaceholder": "Выберите альбом",
|
||||||
"albumId": "ID альбома",
|
"albumId": "ID альбома",
|
||||||
"createAlbumIfMissing": "Создать альбом, если не существует",
|
"createAlbumIfMissing": "Создать альбом, если не существует",
|
||||||
"newAlbumName": "Название нового альбома",
|
"newAlbumName": "Название нового альбома",
|
||||||
|
|||||||
@@ -177,8 +177,12 @@
|
|||||||
|
|
||||||
let displayCommandTrackers = $state(0);
|
let displayCommandTrackers = $state(0);
|
||||||
|
|
||||||
|
const filteredProvider = $derived(globalProviderFilter.id ? providers.find(p => p.id === globalProviderFilter.id) : null);
|
||||||
|
|
||||||
const statCards = $derived(status ? [
|
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: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' },
|
||||||
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
|
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
|
||||||
...(status.command_trackers !== undefined ? [{ icon: 'mdiConsoleLine', label: 'nav.commandTrackers', value: displayCommandTrackers, color: '#8b5cf6' }] : []),
|
...(status.command_trackers !== undefined ? [{ icon: 'mdiConsoleLine', label: 'nav.commandTrackers', value: displayCommandTrackers, color: '#8b5cf6' }] : []),
|
||||||
@@ -234,10 +238,14 @@
|
|||||||
<MdiIcon name={card.icon} size={22} />
|
<MdiIcon name={card.icon} size={22} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm" style="color: var(--color-muted-foreground);">{t(card.label)}</p>
|
<p class="text-sm" style="color: var(--color-muted-foreground);">{card.isProvider ? card.label : t(card.label)}</p>
|
||||||
<p class="stat-value font-mono" style="animation-delay: {i * 80 + 200}ms;">
|
{#if card.isProvider}
|
||||||
{card.value}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
|
<p class="text-lg font-medium" style="color: {card.color};">{card.value}</p>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -318,9 +318,9 @@
|
|||||||
<div class="space-y-3 stagger-children">
|
<div class="space-y-3 stagger-children">
|
||||||
{#each bots as bot}
|
{#each bots as bot}
|
||||||
<Card hover entityId={bot.id}>
|
<Card hover entityId={bot.id}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
|
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiRobot'} size={20} /></span>
|
||||||
<p class="font-medium">{bot.name}</p>
|
<p class="font-medium">{bot.name}</p>
|
||||||
{#if bot.bot_username}
|
{#if bot.bot_username}
|
||||||
@@ -335,21 +335,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
||||||
</div>
|
</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)} />
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
|
||||||
<button onclick={() => toggleSection(bot.id, 'chats')}
|
<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' ? '▲' : '▼'}
|
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => { toggleSection(bot.id, 'listeners'); if (expandedSection[bot.id] === 'listeners') loadListenerStatus(bot.id); }}
|
<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' ? '▲' : '▼'}
|
{t('commandTracker.listeners')} {expandedSection[bot.id] === 'listeners' ? '▲' : '▼'}
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => syncCommands(bot.id)} disabled={modeChanging[bot.id]}
|
<IconButton icon="mdiSync" title={t('telegramBot.syncCommands')} 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="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,12 +85,12 @@
|
|||||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||||
<div class="min-w-[140px]">
|
<div class="min-w-[140px]">
|
||||||
<EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
|
<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)} />
|
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-[140px]">
|
<div class="min-w-[140px]">
|
||||||
<EntitySelect items={templateConfigItems} value={tt.template_config_id}
|
<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)} />
|
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -113,17 +113,17 @@
|
|||||||
<div class="flex items-center gap-2 mt-2">
|
<div class="flex items-center gap-2 mt-2">
|
||||||
<div class="flex-1 min-w-[140px]">
|
<div class="flex-1 min-w-[140px]">
|
||||||
<EntitySelect items={targetItems} value={newLinkTargetId || null}
|
<EntitySelect items={targetItems} value={newLinkTargetId || null}
|
||||||
placeholder={'— ' + t('notificationTracker.addTarget') + ' —'} size="sm"
|
placeholder={t('notificationTracker.addTarget')} size="sm"
|
||||||
onselect={(v) => onchangeNewTarget(Number(v) || 0)} />
|
onselect={(v) => onchangeNewTarget(Number(v) || 0)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-[140px]">
|
<div class="min-w-[140px]">
|
||||||
<EntitySelect items={trackingConfigItems} value={newLinkTrackingConfigId || null}
|
<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)} />
|
onselect={(v) => onchangeNewTrackingConfig(Number(v) || 0)} />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-[140px]">
|
<div class="min-w-[140px]">
|
||||||
<EntitySelect items={templateConfigItems} value={newLinkTemplateConfigId || null}
|
<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)} />
|
onselect={(v) => onchangeNewTemplateConfig(Number(v) || 0)} />
|
||||||
</div>
|
</div>
|
||||||
<button onclick={onaddLink}
|
<button onclick={onaddLink}
|
||||||
|
|||||||
Reference in New Issue
Block a user