396 lines
9.6 KiB
Svelte
396 lines
9.6 KiB
Svelte
<script lang="ts">
|
|
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 { accounts } from "$lib/stores/accounts.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 {
|
|
const saved = await updatePolicy(record.id, next);
|
|
policies = policies.map((p) => (p.id === record.id ? saved : p));
|
|
} 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();
|
|
}
|
|
}
|
|
|
|
async function load(_account: number | null) {
|
|
loading = true;
|
|
chats.load();
|
|
try {
|
|
await reload();
|
|
} catch {
|
|
toasts.error("Не удалось загрузить политики");
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
load(accounts.selectedId);
|
|
});
|
|
</script>
|
|
|
|
{#snippet card(record: PolicyRecord, title: string, onremove?: () => void)}
|
|
<div class="card">
|
|
<div class="card-head">
|
|
<span class="card-title">{title}</span>
|
|
{#if record.account_id === null}
|
|
<span class="shared">для всех аккаунтов</span>
|
|
{/if}
|
|
{#if onremove && record.account_id !== null}
|
|
<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);
|
|
}
|
|
|
|
.shared {
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.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>
|