|
|
@@ -1,5 +1,6 @@
|
|
|
|
<script lang="ts">
|
|
|
|
<script lang="ts">
|
|
|
|
import { slide } from 'svelte/transition';
|
|
|
|
import { slide, fade } from 'svelte/transition';
|
|
|
|
|
|
|
|
import { flip } from 'svelte/animate';
|
|
|
|
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
|
|
|
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
|
|
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
|
|
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
|
|
|
import { t, getLocale } from '$lib/i18n';
|
|
|
|
import { t, getLocale } from '$lib/i18n';
|
|
|
@@ -35,6 +36,10 @@
|
|
|
|
// Per-bot expandable sections
|
|
|
|
// Per-bot expandable sections
|
|
|
|
let chats = $state<Record<number, TelegramChat[]>>({});
|
|
|
|
let chats = $state<Record<number, TelegramChat[]>>({});
|
|
|
|
let chatsLoading = $state<Record<number, boolean>>({});
|
|
|
|
let chatsLoading = $state<Record<number, boolean>>({});
|
|
|
|
|
|
|
|
// Distinct from chatsLoading: refresh keeps the existing list visible
|
|
|
|
|
|
|
|
// instead of swapping it for a placeholder, avoiding the disorienting
|
|
|
|
|
|
|
|
// "everything disappears" flash during Discover.
|
|
|
|
|
|
|
|
let chatsRefreshing = $state<Record<number, boolean>>({});
|
|
|
|
let expandedSection = $state<Record<number, string>>({});
|
|
|
|
let expandedSection = $state<Record<number, string>>({});
|
|
|
|
|
|
|
|
|
|
|
|
// Webhook status per bot
|
|
|
|
// Webhook status per bot
|
|
|
@@ -98,12 +103,13 @@
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function discoverChats(botId: number) {
|
|
|
|
async function discoverChats(botId: number) {
|
|
|
|
chatsLoading = { ...chatsLoading, [botId]: true };
|
|
|
|
if (chatsRefreshing[botId]) return;
|
|
|
|
|
|
|
|
chatsRefreshing = { ...chatsRefreshing, [botId]: true };
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
|
|
|
|
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
|
|
|
|
snackSuccess(t('telegramBot.chatsDiscovered'));
|
|
|
|
snackSuccess(t('telegramBot.chatsDiscovered'));
|
|
|
|
} catch (err: any) { snackError(err.message); }
|
|
|
|
} catch (err: any) { snackError(err.message); }
|
|
|
|
chatsLoading = { ...chatsLoading, [botId]: false };
|
|
|
|
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function deleteChat(botId: number, chatDbId: number) {
|
|
|
|
async function deleteChat(botId: number, chatDbId: number) {
|
|
|
@@ -371,66 +377,80 @@
|
|
|
|
<!-- Chats section -->
|
|
|
|
<!-- Chats section -->
|
|
|
|
{#if expandedSection[bot.id] === 'chats'}
|
|
|
|
{#if expandedSection[bot.id] === 'chats'}
|
|
|
|
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
|
|
|
|
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
|
|
|
|
{#if chatsLoading[bot.id]}
|
|
|
|
{#if chatsLoading[bot.id] && !chats[bot.id]}
|
|
|
|
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
|
|
|
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
|
|
|
{:else if (chats[bot.id] || []).length === 0}
|
|
|
|
{:else if (chats[bot.id] || []).length === 0 && !chatsRefreshing[bot.id]}
|
|
|
|
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
|
|
|
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
|
|
|
{:else}
|
|
|
|
{:else}
|
|
|
|
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 40px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
|
|
|
|
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 40px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
|
|
|
|
<!-- Header -->
|
|
|
|
<div class="chat-list-wrap" class:is-refreshing={chatsRefreshing[bot.id]}>
|
|
|
|
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
|
|
|
|
{#if chatsRefreshing[bot.id]}
|
|
|
|
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
|
|
|
|
<div class="chat-shimmer" aria-hidden="true" transition:fade={{ duration: 180 }}></div>
|
|
|
|
<span>{t('telegramBot.chatName')}</span>
|
|
|
|
{/if}
|
|
|
|
<span style="text-align:center">{t('telegramBot.chatType')}</span>
|
|
|
|
<!-- Header -->
|
|
|
|
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
|
|
|
|
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
|
|
|
|
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
|
|
|
|
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
|
|
|
|
<span style="text-align:center">{t('telegramBot.cmds')}</span>
|
|
|
|
<span>{t('telegramBot.chatName')}</span>
|
|
|
|
<span style="text-align:center">{t('telegramBot.chatId')}</span>
|
|
|
|
<span style="text-align:center">{t('telegramBot.chatType')}</span>
|
|
|
|
<span></span>
|
|
|
|
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
|
|
|
|
</div>
|
|
|
|
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
|
|
|
|
<!-- Rows -->
|
|
|
|
<span style="text-align:center">{t('telegramBot.cmds')}</span>
|
|
|
|
{#each chats[bot.id] as chat}
|
|
|
|
<span style="text-align:center">{t('telegramBot.chatId')}</span>
|
|
|
|
<div style={gridStyle}
|
|
|
|
<span></span>
|
|
|
|
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
|
|
|
|
|
|
|
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
|
|
|
|
|
|
|
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
|
|
|
|
|
|
|
|
title={t('telegramBot.clickToCopy')}
|
|
|
|
|
|
|
|
aria-label={t('telegramBot.clickToCopy')}
|
|
|
|
|
|
|
|
role="button" tabindex="0">
|
|
|
|
|
|
|
|
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
|
|
|
|
|
|
|
|
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
|
|
|
|
|
|
|
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
|
|
|
|
|
|
|
|
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
|
|
|
|
|
|
|
<EntitySelect
|
|
|
|
|
|
|
|
items={LANG_ITEMS}
|
|
|
|
|
|
|
|
value={chat.language_override || ''}
|
|
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
|
|
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
|
|
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
|
|
|
|
|
|
|
title={t('telegramBot.commandsToggle')}
|
|
|
|
|
|
|
|
onclick={() => toggleChatCommands(bot.id, chat)}>
|
|
|
|
|
|
|
|
<span style="position:absolute; top:2px; width:12px; height:12px; border-radius:50%; transition:left 0.2s; left:{chat.commands_enabled ? '14px' : '2px'}; background:{chat.commands_enabled ? 'white' : 'var(--color-muted-foreground)'};" ></span>
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
|
|
|
|
|
|
|
<div style="justify-self:end" class="flex items-center gap-1">
|
|
|
|
|
|
|
|
<IconButton icon="mdiSend" title={t('common.test')} size={14}
|
|
|
|
|
|
|
|
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
|
|
|
|
|
|
|
|
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
|
|
|
|
|
|
|
|
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
|
|
|
|
|
|
|
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/each}
|
|
|
|
<!-- Rows -->
|
|
|
|
|
|
|
|
{#each (chats[bot.id] || []) as chat (chat.id)}
|
|
|
|
|
|
|
|
<div style={gridStyle}
|
|
|
|
|
|
|
|
class="chat-row text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
|
|
|
|
|
|
|
animate:flip={{ duration: 280 }}
|
|
|
|
|
|
|
|
in:fade={{ duration: 220, delay: 60 }}
|
|
|
|
|
|
|
|
out:fade={{ duration: 140 }}
|
|
|
|
|
|
|
|
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
|
|
|
|
|
|
|
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
|
|
|
|
|
|
|
|
title={t('telegramBot.clickToCopy')}
|
|
|
|
|
|
|
|
aria-label={t('telegramBot.clickToCopy')}
|
|
|
|
|
|
|
|
role="button" tabindex="0">
|
|
|
|
|
|
|
|
<span class="font-medium truncate">{chat.title || chat.username || t('common.unknown')}</span>
|
|
|
|
|
|
|
|
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
|
|
|
|
|
|
|
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
|
|
|
|
|
|
|
|
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
|
|
|
|
|
|
|
<EntitySelect
|
|
|
|
|
|
|
|
items={LANG_ITEMS}
|
|
|
|
|
|
|
|
value={chat.language_override || ''}
|
|
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
|
|
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div style="justify-self:center" role="presentation" onclick={(e: MouseEvent) => e.stopPropagation()} onkeydown={(e: KeyboardEvent) => e.stopPropagation()}>
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
|
|
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
|
|
|
|
|
|
|
title={t('telegramBot.commandsToggle')}
|
|
|
|
|
|
|
|
onclick={() => toggleChatCommands(bot.id, chat)}>
|
|
|
|
|
|
|
|
<span style="position:absolute; top:2px; width:12px; height:12px; border-radius:50%; transition:left 0.2s; left:{chat.commands_enabled ? '14px' : '2px'}; background:{chat.commands_enabled ? 'white' : 'var(--color-muted-foreground)'};" ></span>
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
|
|
|
|
|
|
|
<div style="justify-self:end" class="flex items-center gap-1">
|
|
|
|
|
|
|
|
<IconButton icon="mdiSend" title={t('common.test')} size={14}
|
|
|
|
|
|
|
|
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
|
|
|
|
|
|
|
|
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
|
|
|
|
|
|
|
|
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
|
|
|
|
|
|
|
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
|
|
{#if chatsRefreshing[bot.id] && (chats[bot.id] || []).length === 0}
|
|
|
|
|
|
|
|
<p class="text-xs text-[var(--color-muted-foreground)] py-2 px-2">{t('telegramBot.discoveringChats')}</p>
|
|
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
{/if}
|
|
|
|
{/if}
|
|
|
|
<button onclick={() => discoverChats(bot.id)}
|
|
|
|
<button onclick={() => discoverChats(bot.id)}
|
|
|
|
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
|
|
|
|
disabled={chatsRefreshing[bot.id]}
|
|
|
|
<MdiIcon name="mdiSync" size={14} />
|
|
|
|
class="discover-btn text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1 disabled:opacity-70 disabled:cursor-default disabled:no-underline">
|
|
|
|
{t('telegramBot.discoverChats')}
|
|
|
|
<span class="discover-icon" class:is-spinning={chatsRefreshing[bot.id]}>
|
|
|
|
|
|
|
|
<MdiIcon name="mdiSync" size={14} />
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
{chatsRefreshing[bot.id] ? t('telegramBot.discoveringChats') : t('telegramBot.discoverChats')}
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/if}
|
|
|
|
{/if}
|
|
|
@@ -553,3 +573,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
|
|
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
|
|
|
/* Chat list — smooth refresh state.
|
|
|
|
|
|
|
|
The list stays mounted during Discover; we only dim it slightly
|
|
|
|
|
|
|
|
and run a thin shimmer bar across the top so the user sees
|
|
|
|
|
|
|
|
"refreshing" instead of "everything vanished and came back". */
|
|
|
|
|
|
|
|
.chat-list-wrap {
|
|
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
|
|
transition: opacity 0.25s ease, filter 0.25s ease;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.chat-list-wrap.is-refreshing {
|
|
|
|
|
|
|
|
opacity: 0.78;
|
|
|
|
|
|
|
|
filter: saturate(0.9);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.chat-list-wrap.is-refreshing .chat-row {
|
|
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.chat-shimmer {
|
|
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
|
|
height: 2px;
|
|
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
|
|
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
|
|
|
|
|
|
z-index: 2;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.chat-shimmer::after {
|
|
|
|
|
|
|
|
content: '';
|
|
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
|
|
background: linear-gradient(
|
|
|
|
|
|
|
|
90deg,
|
|
|
|
|
|
|
|
transparent 0%,
|
|
|
|
|
|
|
|
color-mix(in srgb, var(--color-primary) 70%, transparent) 50%,
|
|
|
|
|
|
|
|
transparent 100%
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
transform: translateX(-100%);
|
|
|
|
|
|
|
|
animation: chat-shimmer-sweep 1.15s ease-in-out infinite;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
@keyframes chat-shimmer-sweep {
|
|
|
|
|
|
|
|
0% { transform: translateX(-100%); }
|
|
|
|
|
|
|
|
100% { transform: translateX(100%); }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.discover-icon {
|
|
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
|
|
transition: transform 0.2s ease;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.discover-icon.is-spinning {
|
|
|
|
|
|
|
|
animation: discover-spin 1s linear infinite;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
@keyframes discover-spin {
|
|
|
|
|
|
|
|
to { transform: rotate(-360deg); }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
|
|
|
|
|
|
.chat-shimmer::after,
|
|
|
|
|
|
|
|
.discover-icon.is-spinning {
|
|
|
|
|
|
|
|
animation: none;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.chat-list-wrap {
|
|
|
|
|
|
|
|
transition: none;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
|
|