feat: web UI chat render, panels, presence + analytics
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
createPolicy,
|
||||
deletePolicy,
|
||||
listFolders,
|
||||
listPolicies,
|
||||
updatePolicy,
|
||||
} from "$lib/api/endpoints";
|
||||
import type {
|
||||
CaptureToggles,
|
||||
Folder,
|
||||
PolicyRecord,
|
||||
PolicyScopeType,
|
||||
} from "$lib/api/types";
|
||||
import CaptureToggleList from "$lib/components/policy/CaptureToggleList.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { chats } from "$lib/stores/chats.svelte";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
let policies = $state<PolicyRecord[]>([]);
|
||||
let folders = $state<Folder[]>([]);
|
||||
let loading = $state(true);
|
||||
let pickFolder = $state(false);
|
||||
let pickChat = $state(false);
|
||||
let chatFilter = $state("");
|
||||
|
||||
const DEFAULT_SCOPES: { label: string; scope: PolicyScopeType }[] = [
|
||||
{ scope: "default_dm", label: "Личные чаты" },
|
||||
{ scope: "default_group", label: "Группы" },
|
||||
{ scope: "default_channel", label: "Каналы" },
|
||||
];
|
||||
|
||||
const ALL_FALSE: CaptureToggles = {
|
||||
messages: false,
|
||||
media: false,
|
||||
self_destruct_media: false,
|
||||
stt: false,
|
||||
reactions: false,
|
||||
track_edits_deletes: false,
|
||||
profile_history: false,
|
||||
stories: false,
|
||||
presence: false,
|
||||
backfill: false,
|
||||
};
|
||||
|
||||
function pickToggles(t: CaptureToggles): CaptureToggles {
|
||||
return {
|
||||
messages: t.messages,
|
||||
media: t.media,
|
||||
self_destruct_media: t.self_destruct_media,
|
||||
stt: t.stt,
|
||||
reactions: t.reactions,
|
||||
track_edits_deletes: t.track_edits_deletes,
|
||||
profile_history: t.profile_history,
|
||||
stories: t.stories,
|
||||
presence: t.presence,
|
||||
backfill: t.backfill,
|
||||
};
|
||||
}
|
||||
|
||||
const defaults = $derived(
|
||||
DEFAULT_SCOPES.map((d) => ({
|
||||
label: d.label,
|
||||
record: policies.find((p) => p.scope_type === d.scope),
|
||||
})).filter((d): d is { label: string; record: PolicyRecord } =>
|
||||
Boolean(d.record)
|
||||
)
|
||||
);
|
||||
const folderPolicies = $derived(
|
||||
policies.filter((p) => p.scope_type === "folder")
|
||||
);
|
||||
const chatPolicies = $derived(
|
||||
policies.filter((p) => p.scope_type === "chat")
|
||||
);
|
||||
const availableFolders = $derived(
|
||||
folders.filter(
|
||||
(f) => !folderPolicies.some((p) => p.scope_id === f.folder_id)
|
||||
)
|
||||
);
|
||||
const availableChats = $derived(
|
||||
chats.list
|
||||
.filter((c) => !chatPolicies.some((p) => p.scope_id === c.chat_id))
|
||||
.filter((c) =>
|
||||
(c.title ?? "").toLowerCase().includes(chatFilter.trim().toLowerCase())
|
||||
)
|
||||
.slice(0, 40)
|
||||
);
|
||||
|
||||
function folderTitle(id: number | null): string {
|
||||
return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`;
|
||||
}
|
||||
|
||||
function chatTitle(id: number | null): string {
|
||||
return chats.byId(id ?? 0)?.title ?? `Чат ${id}`;
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
[policies, folders] = await Promise.all([listPolicies(), listFolders()]);
|
||||
}
|
||||
|
||||
async function toggle(
|
||||
record: PolicyRecord,
|
||||
key: keyof CaptureToggles,
|
||||
value: boolean
|
||||
) {
|
||||
const next = { ...pickToggles(record), [key]: value };
|
||||
policies = policies.map((p) =>
|
||||
p.id === record.id ? { ...p, [key]: value } : p
|
||||
);
|
||||
try {
|
||||
await updatePolicy(record.id, next);
|
||||
} catch {
|
||||
toasts.error("Не удалось сохранить политику");
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function addOverride(scope: "folder" | "chat", scopeId: number) {
|
||||
try {
|
||||
const record = await createPolicy({
|
||||
...ALL_FALSE,
|
||||
scope_type: scope,
|
||||
scope_id: scopeId,
|
||||
});
|
||||
policies = [...policies, record];
|
||||
pickFolder = false;
|
||||
pickChat = false;
|
||||
chatFilter = "";
|
||||
} catch {
|
||||
toasts.error("Не удалось создать оверрайд");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOverride(record: PolicyRecord) {
|
||||
policies = policies.filter((p) => p.id !== record.id);
|
||||
try {
|
||||
await deletePolicy(record.id);
|
||||
} catch {
|
||||
toasts.error("Не удалось удалить оверрайд");
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
chats.load();
|
||||
try {
|
||||
await reload();
|
||||
} catch {
|
||||
toasts.error("Не удалось загрузить политики");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet card(record: PolicyRecord, title: string, onremove?: () => void)}
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">{title}</span>
|
||||
{#if onremove}
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
aria-label="Удалить оверрайд"
|
||||
onclick={onremove}
|
||||
>
|
||||
<Icon name="close" size="1.125rem" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<CaptureToggleList
|
||||
toggles={record}
|
||||
onchange={(key, value) => toggle(record, key, value)}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if loading}
|
||||
<div class="state"><Spinner /></div>
|
||||
{:else}
|
||||
<div class="editor">
|
||||
<section>
|
||||
<div class="section-title">По умолчанию</div>
|
||||
{#each defaults as item (item.record.id)}
|
||||
{@render card(item.record, item.label)}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">Оверрайды папок</div>
|
||||
{#each folderPolicies as record (record.id)}
|
||||
{@render card(record, folderTitle(record.scope_id), () =>
|
||||
removeOverride(record)
|
||||
)}
|
||||
{/each}
|
||||
{#if availableFolders.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="add"
|
||||
onclick={() => {
|
||||
pickFolder = !pickFolder;
|
||||
}}
|
||||
>
|
||||
<Icon name="add" size="1.25rem" />
|
||||
<span>Добавить папку</span>
|
||||
</button>
|
||||
{#if pickFolder}
|
||||
<div class="picker">
|
||||
{#each availableFolders as folder (folder.folder_id)}
|
||||
<button
|
||||
type="button"
|
||||
class="pick-row"
|
||||
onclick={() => addOverride("folder", folder.folder_id)}
|
||||
>
|
||||
{folder.title}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">Оверрайды чатов</div>
|
||||
{#each chatPolicies as record (record.id)}
|
||||
{@render card(record, chatTitle(record.scope_id), () =>
|
||||
removeOverride(record)
|
||||
)}
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="add"
|
||||
onclick={() => {
|
||||
pickChat = !pickChat;
|
||||
}}
|
||||
>
|
||||
<Icon name="add" size="1.25rem" />
|
||||
<span>Добавить чат</span>
|
||||
</button>
|
||||
{#if pickChat}
|
||||
<div class="picker">
|
||||
<input
|
||||
class="filter"
|
||||
type="text"
|
||||
placeholder="Поиск чата"
|
||||
bind:value={chatFilter}
|
||||
>
|
||||
{#each availableChats as chat (chat.chat_id)}
|
||||
<button
|
||||
type="button"
|
||||
class="pick-row"
|
||||
onclick={() => addOverride("chat", chat.chat_id)}
|
||||
>
|
||||
{chat.title ?? `Чат ${chat.chat_id}`}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.editor {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 0.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
flex: 1;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.remove {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
padding: 0.25rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--color-text-secondary);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
border: 0;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-primary);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.filter {
|
||||
margin: 0.25rem 0.5rem 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-borders);
|
||||
border-radius: var(--border-radius-default);
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-background);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pick-row {
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
text-align: start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user