Phase 10: Telegram bot commands + Phase 11: Snackbar notifications
All checks were successful
Validate / Hassfest (push) Successful in 3s
All checks were successful
Validate / Hassfest (push) Successful in 3s
Phase 10 — Telegram Bot Commands: - Add commands_config JSON field to TelegramBot model (enabled cmds, default count, response mode, rate limits, locale) - Create command handler with 14 commands: /status, /albums, /events, /summary, /latest, /memory, /random, /search, /find, /person, /place, /favorites, /people, /help - Add search_smart, search_metadata, search_by_person, get_random, download_asset, get_asset_thumbnail to ImmichClient - Auto-register commands with Telegram setMyCommands API (EN+RU) - Rate limiting per chat per command category - Media mode: download thumbnails and send as photos to Telegram - Webhook handler routes /commands before falling through to AI chat - Frontend: expandable Commands section per bot with checkboxes, count/mode/locale settings, rate limit inputs, sync button Phase 11 — Snackbar Notifications: - Create snackbar store (snackbar.svelte.ts) with $state rune - Create Snackbar component with fly/fade transitions, typed colors - Mount globally in +layout.svelte - Replace all alert() calls with typed snackbar notifications - Add success snacks to all CRUD operations across all pages - 4 types: success (3s), error (5s), info (3s), warning (4s) - Max 3 visible, auto-dismiss, manual dismiss via X button Both: Add ~30 i18n keys (EN+RU) for commands UI and snack messages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
88
frontend/src/lib/components/Snackbar.svelte
Normal file
88
frontend/src/lib/components/Snackbar.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import { getSnacks, removeSnack, type Snack } from '$lib/stores/snackbar.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
const snacks = $derived(getSnacks());
|
||||
|
||||
let expandedIds = $state<Set<number>>(new Set());
|
||||
|
||||
function toggleDetail(id: number) {
|
||||
const next = new Set(expandedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
expandedIds = next;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, string> = {
|
||||
success: 'mdiCheckCircle',
|
||||
error: 'mdiAlertCircle',
|
||||
info: 'mdiInformation',
|
||||
warning: 'mdiAlert',
|
||||
};
|
||||
|
||||
const borderColorMap: Record<string, string> = {
|
||||
success: '#22c55e',
|
||||
error: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
warning: '#f59e0b',
|
||||
};
|
||||
|
||||
const iconColorMap: Record<string, string> = {
|
||||
success: '#22c55e',
|
||||
error: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
warning: '#f59e0b',
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if snacks.length > 0}
|
||||
<div
|
||||
style="position: fixed; left: 50%; transform: translateX(-50%); z-index: 9999; display: flex; flex-direction: column; gap: 0.5rem; width: 90%; max-width: 28rem; pointer-events: none;"
|
||||
class="snackbar-container"
|
||||
>
|
||||
{#each snacks as snack (snack.id)}
|
||||
<div
|
||||
in:fly={{ y: 50, duration: 250 }}
|
||||
out:fade={{ duration: 150 }}
|
||||
style="pointer-events: auto; border-left: 4px solid {borderColorMap[snack.type]}; background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(8px); border-radius: 0.5rem; padding: 0.75rem 1rem; display: flex; align-items: flex-start; gap: 0.5rem; color: #f1f5f9; box-shadow: 0 4px 12px rgba(0,0,0,0.3);"
|
||||
>
|
||||
<span style="color: {iconColorMap[snack.type]}; flex-shrink: 0; margin-top: 1px;">
|
||||
<MdiIcon name={iconMap[snack.type]} size={18} />
|
||||
</span>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<p style="font-size: 0.875rem; line-height: 1.25rem; margin: 0;">{snack.message}</p>
|
||||
{#if snack.detail}
|
||||
<button
|
||||
onclick={() => toggleDetail(snack.id)}
|
||||
style="font-size: 0.75rem; color: #94a3b8; background: none; border: none; padding: 0; margin-top: 0.25rem; cursor: pointer; text-decoration: underline;"
|
||||
>
|
||||
{expandedIds.has(snack.id) ? 'Hide details' : 'Show details'}
|
||||
</button>
|
||||
{#if expandedIds.has(snack.id)}
|
||||
<pre style="font-size: 0.75rem; color: #94a3b8; margin: 0.25rem 0 0; white-space: pre-wrap; word-break: break-word;">{snack.detail}</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => removeSnack(snack.id)}
|
||||
style="flex-shrink: 0; background: none; border: none; color: #94a3b8; cursor: pointer; padding: 0; line-height: 1; font-size: 1.125rem;"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<MdiIcon name="mdiClose" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.snackbar-container {
|
||||
bottom: 5rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.snackbar-container {
|
||||
bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user