feat(frontend): capture policy shows what each rule changes, rule card opens from folder and chat menus and can trash downloaded files, trash is paged

This commit is contained in:
hh committed 2026-10-14 04:20:38 +02:00
1 parent f646f29ff9
commit cb01d7dff8
16 files changed
+1520 -353

No files matched your search

+41 -3
View File
@@ -26,13 +26,17 @@ import type {
PolicyChatKind, PolicyChatKind,
PolicyCreate, PolicyCreate,
PolicyRecord, PolicyRecord,
PolicyRule,
PolicyScope,
PresenceHourly, PresenceHourly,
PresenceSample, PresenceSample,
ReactionView, ReactionView,
ResponseStats, ResponseStats,
ScopeTarget,
SearchHit, SearchHit,
StorageFile, StorageFile,
StorageGroupName, StorageGroupName,
StorageScope,
StorageSummary, StorageSummary,
StoryView, StoryView,
TrashItem, TrashItem,
@@ -148,6 +152,23 @@ export function deletePolicy(id: number): Promise<void> {
return request<void>(`/policy/${id}`, { method: "DELETE" }); return request<void>(`/policy/${id}`, { method: "DELETE" });
} }
function scopeQuery(target: ScopeTarget): Record<string, number> {
return "chatId" in target
? { chat_id: target.chatId }
: { folder_id: target.folderId };
}
export function getPolicyOverview(): Promise<PolicyRule[]> {
return request<PolicyRule[]>("/policy/overview", { account: true });
}
export function getPolicyScope(target: ScopeTarget): Promise<PolicyScope> {
return request<PolicyScope>("/policy/scope", {
account: true,
query: scopeQuery(target),
});
}
export function effectivePolicy(query: { export function effectivePolicy(query: {
chat_id: number; chat_id: number;
is_bot?: boolean; is_bot?: boolean;
@@ -601,8 +622,25 @@ export function getStorageFiles(
}); });
} }
export function getTrash(): Promise<TrashItem[]> { export function getTrash(page: Page = {}): Promise<TrashItem[]> {
return request<TrashItem[]>("/storage/trash", { account: true }); return request<TrashItem[]>("/storage/trash", {
account: true,
query: { ...page },
});
}
export function getStorageScope(target: ScopeTarget): Promise<StorageScope> {
return request<StorageScope>("/storage/scope", {
account: true,
query: scopeQuery(target),
});
}
export function trashScope(target: ScopeTarget): Promise<TrashOutcome> {
return request<TrashOutcome>("/storage/trash/scope", {
body: { account_id: accounts.selectedId, ...scopeQuery(target) },
method: "POST",
});
} }
export function moveToTrash(mediaIds: number[]): Promise<TrashOutcome> { export function moveToTrash(mediaIds: number[]): Promise<TrashOutcome> {
@@ -612,7 +650,7 @@ export function moveToTrash(mediaIds: number[]): Promise<TrashOutcome> {
}); });
} }
export function restoreFromTrash(keys: string[]): Promise<TrashOutcome> { export function restoreFromTrash(keys: string[] | null): Promise<TrashOutcome> {
return request<TrashOutcome>("/storage/trash/restore", { return request<TrashOutcome>("/storage/trash/restore", {
body: { account_id: accounts.selectedId, keys }, body: { account_id: accounts.selectedId, keys },
method: "POST", method: "POST",
+28
View File
@@ -423,6 +423,24 @@ export interface PolicyRecord extends CaptureToggles {
scope_type: PolicyScopeType; scope_type: PolicyScopeType;
} }
export interface PolicyParent {
scope_id: number | null;
scope_type: PolicyScopeType;
title: string | null;
toggles: CaptureToggles;
}
export interface PolicyRule extends PolicyRecord {
parent: PolicyParent | null;
title: string | null;
}
export interface PolicyScope {
parent: PolicyParent | null;
rule: PolicyRecord | null;
title: string | null;
}
export type PolicyChatKind = "dm" | "group" | "channel"; export type PolicyChatKind = "dm" | "group" | "channel";
export interface PolicyCreate extends CaptureToggles { export interface PolicyCreate extends CaptureToggles {
@@ -613,3 +631,13 @@ export interface TrashOutcome {
files: number; files: number;
keys: string[]; keys: string[];
} }
export interface StorageScope {
bytes: number;
chats: number;
files: number;
gone: number;
kept: number;
}
export type ScopeTarget = { chatId: number } | { folderId: number };
@@ -15,6 +15,7 @@
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js"; import { discover } from "#lib/stores/discover.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -186,6 +187,11 @@
onselect={() => ui.openPanel("stories")} onselect={() => ui.openPanel("stories")}
>Сторис</ContextMenuItem >Сторис</ContextMenuItem
> >
<ContextMenuItem
icon="settings"
onselect={() => policyUi.edit({ chatId }, title)}
>Политика захвата</ContextMenuItem
>
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
<div class="actions"> <div class="actions">
@@ -7,6 +7,7 @@
import { formatListDate } from "#lib/format/datetime.js"; import { formatListDate } from "#lib/format/datetime.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -108,6 +109,11 @@
<ContextMenuItem icon="play-story" onselect={() => openWith("stories")} <ContextMenuItem icon="play-story" onselect={() => openWith("stories")}
>Сторис</ContextMenuItem >Сторис</ContextMenuItem
> >
<ContextMenuItem
icon="settings"
onselect={() => policyUi.edit({ chatId: chat.chat_id }, title)}
>Политика захвата</ContextMenuItem
>
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
@@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import { folders } from "#lib/stores/folders.svelte.js"; import { folders } from "#lib/stores/folders.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
interface Tab { interface Tab {
id: number | null; id: number | null;
@@ -21,6 +24,12 @@
) )
); );
function editPolicy(tab: Tab) {
if (tab.id !== null) {
policyUi.edit({ folderId: tab.id }, tab.title);
}
}
let containerEl = $state<HTMLDivElement>(); let containerEl = $state<HTMLDivElement>();
let indicatorEl = $state<HTMLDivElement>(); let indicatorEl = $state<HTMLDivElement>();
let clipPath = $state(""); let clipPath = $state("");
@@ -64,9 +73,31 @@
<div class="container" bind:this={containerEl} class:ready={clipPath !== ""}> <div class="container" bind:this={containerEl} class:ready={clipPath !== ""}>
{#each tabs as tab (tab.id)} {#each tabs as tab (tab.id)}
{#if tab.id === null}
<button class="tab" onclick={() => folders.select(tab.id)} type="button"> <button class="tab" onclick={() => folders.select(tab.id)} type="button">
{tab.title} {tab.title}
</button> </button>
{:else}
<ContextMenu>
{#snippet children({
props,
})}
<button
{...props}
class="tab"
onclick={() => folders.select(tab.id)}
type="button"
>
{tab.title}
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}>
Политика захвата
</ContextMenuItem>
{/snippet}
</ContextMenu>
{/if}
{/each} {/each}
<div <div
@@ -19,6 +19,7 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte"; import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
const titles: Record<RightPanel, string> = { const titles: Record<RightPanel, string> = {
@@ -78,7 +79,9 @@
{#if ui.rightPanel === "profile"} {#if ui.rightPanel === "profile"}
<ProfilePanel /> <ProfilePanel />
{:else if ui.rightPanel === "policy"} {:else if ui.rightPanel === "policy"}
{#key `${accounts.selectedId}:${policyUi.revision}`}
<PolicyEditor /> <PolicyEditor />
{/key}
{:else if ui.rightPanel === "jobs"} {:else if ui.rightPanel === "jobs"}
<JobsPanel /> <JobsPanel />
{:else if ui.rightPanel === "presence"} {:else if ui.rightPanel === "presence"}
@@ -4,6 +4,7 @@
import { SvelteSet } from "svelte/reactivity"; import { SvelteSet } from "svelte/reactivity";
import { trashedUrl } from "#lib/api/client.js"; import { trashedUrl } from "#lib/api/client.js";
import { import {
getStorageSummary,
getTrash, getTrash,
purgeTrash, purgeTrash,
restoreFromTrash, restoreFromTrash,
@@ -29,7 +30,12 @@
import { isMobile } from "#lib/viewport.js"; import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
const PAGE = 30;
let items = $state<TrashItem[]>([]); let items = $state<TrashItem[]>([]);
let total = $state({ bytes: 0, files: 0 });
let done = $state(false);
let everything = $state(false);
let loading = $state(true); let loading = $state(true);
let failed = $state(false); let failed = $state(false);
let busy = $state(false); let busy = $state(false);
@@ -42,7 +48,7 @@
const heaviest = $derived( const heaviest = $derived(
Math.max(0, ...items.map((item) => item.file_size ?? 0)) Math.max(0, ...items.map((item) => item.file_size ?? 0))
); );
const totalBytes = $derived(bytesOf(items)); const dialogOpen = $derived(doomed !== null || everything);
const chosen = $derived( const chosen = $derived(
items.filter((item) => selected.has(item.storage_key)) items.filter((item) => selected.has(item.storage_key))
); );
@@ -74,12 +80,19 @@
return chats.byId(chatId)?.title ?? `Чат ${chatId}`; return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
} }
async function load() { async function loadTotal() {
const summary = await getStorageSummary();
total = { bytes: summary.trash_bytes, files: summary.trash_files };
}
async function loadMore() {
loading = true; loading = true;
failed = false; failed = false;
try { try {
items = await getTrash(); const batch = await getTrash({ limit: PAGE, offset: items.length });
chats.ensureMany(items.map((item) => item.chat_id)); chats.ensureMany(batch.map((item) => item.chat_id));
items = [...items, ...batch];
done = batch.length < PAGE;
} catch { } catch {
failed = true; failed = true;
} finally { } finally {
@@ -87,6 +100,13 @@
} }
} }
async function load() {
items = [];
done = false;
selected.clear();
await Promise.all([loadMore(), loadTotal().catch(() => undefined)]);
}
function toggle(key: string) { function toggle(key: string) {
problem = null; problem = null;
if (!selected.delete(key)) { if (!selected.delete(key)) {
@@ -98,6 +118,7 @@
const gone = new Set(keys); const gone = new Set(keys);
items = items.filter((item) => !gone.has(item.storage_key)); items = items.filter((item) => !gone.has(item.storage_key));
selected.clear(); selected.clear();
loadTotal().catch(() => undefined);
} }
async function restore(list: TrashItem[]) { async function restore(list: TrashItem[]) {
@@ -117,17 +138,34 @@
} }
} }
async function purge() { async function restoreAll() {
if (busy || doomed === null) { if (busy) {
return; return;
} }
busy = true; busy = true;
problem = null; problem = null;
const keys = doomed.map((item) => item.storage_key); try {
await restoreFromTrash(null);
await load();
} catch {
problem = "Не удалось вернуть файлы";
} finally {
busy = false;
}
}
async function purge() {
if (busy || (doomed === null && !everything)) {
return;
}
busy = true;
problem = null;
const keys = doomed?.map((item) => item.storage_key) ?? null;
try { try {
await purgeTrash(keys); await purgeTrash(keys);
drop(keys);
doomed = null; doomed = null;
everything = false;
await load();
} catch { } catch {
problem = "Не удалось удалить файлы"; problem = "Не удалось удалить файлы";
} finally { } finally {
@@ -157,17 +195,22 @@
вернуть. вернуть.
</p> </p>
{#if items.length > 0} {#if total.files > 0}
<button class="act" disabled={busy} onclick={restoreAll} type="button">
<Icon name="undo" size="1.375rem" />
<span class="label">Вернуть всё</span>
<span class="value">{filesLabel(total.files)}</span>
</button>
<button <button
class="clear" class="act danger"
onclick={() => { onclick={() => {
doomed = items; everything = true;
}} }}
type="button" type="button"
> >
<Icon name="delete" size="1.375rem" /> <Icon name="delete" size="1.375rem" />
<span class="label">Очистить корзину</span> <span class="label">Очистить корзину</span>
<span class="value">{formatBytes(totalBytes)}</span> <span class="value">{formatBytes(total.bytes)}</span>
</button> </button>
{/if} {/if}
@@ -219,12 +262,16 @@
</div> </div>
{:else if items.length === 0} {:else if items.length === 0}
<div class="state"><p>Корзина пуста.</p></div> <div class="state"><p>Корзина пуста.</p></div>
{:else if !done}
<button class="more" onclick={loadMore} type="button">
Показать ещё
</button>
{/if} {/if}
</div> </div>
{#if chosen.length > 0} {#if chosen.length > 0}
<SelectionBar <SelectionBar
failure={doomed === null ? problem : null} failure={dialogOpen ? null : problem}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined} note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}" title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
warn warn
@@ -250,7 +297,7 @@
<span>Вернуть</span> <span>Вернуть</span>
</Button> </Button>
</SelectionBar> </SelectionBar>
{:else if problem && doomed === null} {:else if problem && !dialogOpen}
<SelectionBar failure={problem} title="Не получилось"> <SelectionBar failure={problem} title="Не получилось">
<Button <Button
onclick={() => { onclick={() => {
@@ -269,26 +316,40 @@
onOpenChange={(next) => { onOpenChange={(next) => {
if (!next) { if (!next) {
doomed = null; doomed = null;
everything = false;
problem = null; problem = null;
} }
}} }}
open={doomed !== null} open={doomed !== null || everything}
> >
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" /> <Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title"> <Dialog.Title class="dialog-title">
{doomed?.length === items.length {everything ? "Очистить корзину?" : "Удалить насовсем?"}
? "Очистить корзину?"
: "Удалить насовсем?"}
</Dialog.Title> </Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close"> <Dialog.Close aria-label="Закрыть" class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
{#if doomed}
<div class="dialog-body"> <div class="dialog-body">
{#if everything}
<p class="confirm">
{filesLabel(total.files)}, {formatBytes(total.bytes)},
{plural(
total.files,
"будет удалён",
"будут удалены",
"будут удалены"
)}
с диска.
</p>
<p class="confirm rest">
То, что ещё есть в Telegram, потом можно скачать заново. Файлы из
удалённых там сообщений пропадут насовсем.
</p>
{:else if doomed}
<p class="confirm"> <p class="confirm">
{filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))}, {filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))},
{plural( {plural(
@@ -316,11 +377,11 @@
можно скачать из Telegram заново. можно скачать из Telegram заново.
</p> </p>
{/if} {/if}
{/if}
{#if problem} {#if problem}
<p class="confirm failed">{problem}.</p> <p class="confirm failed">{problem}.</p>
{/if} {/if}
</div> </div>
{/if}
<div class="dialog-actions"> <div class="dialog-actions">
<Dialog.Close> <Dialog.Close>
{#snippet child({ {#snippet child({
@@ -366,7 +427,7 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.clear { .act {
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@@ -382,11 +443,20 @@
font: inherit; font: inherit;
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-error); color: var(--color-primary);
text-align: start; text-align: start;
background: none; background: none;
&.danger {
color: var(--color-error);
}
&:disabled {
cursor: default;
opacity: 0.5;
}
.label { .label {
flex: 1; flex: 1;
} }
@@ -398,17 +468,38 @@
} }
&:focus-visible { &:focus-visible {
outline: 2px solid var(--color-error); outline: 2px solid currentcolor;
outline-offset: -2px; outline-offset: -2px;
} }
@media (hover: hover) { @media (hover: hover) {
&:hover { &:not(:disabled):hover {
background-color: var(--color-chat-hover); background-color: var(--color-chat-hover);
} }
} }
} }
.more {
cursor: pointer;
width: calc(100% - 1rem);
margin: 0.5rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.list { .list {
flex: 1; flex: 1;
padding: 0.25rem 0 0.5rem; padding: 0.25rem 0 0.5rem;
@@ -1,106 +0,0 @@
<script lang="ts">
import type { CaptureToggles } from "#lib/api/types.js";
interface Props {
disabled?: boolean;
onchange: (key: keyof CaptureToggles, value: boolean) => void;
toggles: CaptureToggles;
}
let { toggles, disabled = false, onchange }: Props = $props();
const rows: { key: keyof CaptureToggles; label: string }[] = [
{ key: "messages", label: "Сообщения" },
{ key: "media", label: "Медиа" },
{ key: "self_destruct_media", label: "Самоуничтожающиеся" },
{ key: "stt", label: "Расшифровка голоса" },
{ key: "reactions", label: "Реакции" },
{ key: "track_edits_deletes", label: "Правки и удаления" },
{ key: "profile_history", label: "История профиля" },
{ key: "stories", label: "Сторис" },
{ key: "presence", label: "Presence" },
{ key: "backfill", label: "Бэкфилл" },
];
</script>
<div class="toggles">
{#each rows as row (row.key)}
<button
aria-checked={toggles[row.key]}
class="toggle-row"
{disabled}
onclick={() => onchange(row.key, !toggles[row.key])}
role="switch"
type="button"
>
<span class="label">{row.label}</span>
<span class="switch" class:on={toggles[row.key]}>
<span class="knob"></span>
</span>
</button>
{/each}
</div>
<style lang="scss">
.toggle-row {
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: 0.5rem 1rem;
border: 0;
font-size: 0.9375rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--color-chat-hover);
}
&:disabled {
cursor: default;
opacity: 0.5;
}
}
.label {
flex: 1;
}
.switch {
flex-shrink: 0;
display: flex;
align-items: center;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders);
transition: background-color 0.15s ease;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease;
.on & {
transform: translateX(0.875rem);
}
}
</style>
@@ -0,0 +1,587 @@
<script lang="ts">
import { onMount } from "svelte";
import {
createPolicy,
deletePolicy,
getPolicyScope,
getStorageScope,
restoreFromTrash,
trashScope,
updatePolicy,
} from "#lib/api/endpoints.js";
import type {
CaptureToggles,
PolicyParent,
PolicyRecord,
PolicyScopeType,
ScopeTarget,
StorageScope,
TrashOutcome,
} from "#lib/api/types.js";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes } from "#lib/format/media.js";
import {
parentLabel,
pickToggles,
preset,
sameToggles,
summary,
TOGGLE_KEYS,
TOGGLE_LABELS,
type ToggleKey,
} from "#lib/format/policy.js";
import { plural } from "#lib/format/shares.js";
import { filesLabel } from "#lib/format/storage.js";
interface Props {
onchange?: () => void;
parent?: PolicyParent | null;
rule?: PolicyRecord | null;
scopeId: number | null;
scopeType: PolicyScopeType;
}
let {
scopeType,
scopeId,
rule: initialRule,
parent: initialParent,
onchange,
}: Props = $props();
const PRESETS: { label: string; toggles: CaptureToggles }[] = [
{ label: "Всё", toggles: preset("all") },
{ label: "Только сообщения", toggles: preset(["messages"]) },
{ label: "Ничего", toggles: preset([]) },
];
let rule = $state<PolicyRecord | null>(null);
let parent = $state<PolicyParent | null>(null);
let loading = $state(false);
let saving = $state(false);
let problem = $state<string | null>(null);
let files = $state<StorageScope | null>(null);
let confirming = $state(false);
let moving = $state(false);
let moved = $state<TrashOutcome | null>(null);
const isChat = $derived(scopeType === "chat");
const isFolder = $derived(scopeType === "folder");
const target = $derived<ScopeTarget | null>(scopeTarget());
const shown = $derived<CaptureToggles | null>(
rule ?? parent?.toggles ?? null
);
const hint = $derived(inheritance());
function scopeTarget(): ScopeTarget | null {
if (scopeId === null) {
return null;
}
if (scopeType === "chat") {
return { chatId: scopeId };
}
return scopeType === "folder" ? { folderId: scopeId } : null;
}
function inheritance(): string {
if (isChat && parent) {
const from = parentLabel(parent.scope_type, parent.title);
return rule
? `Без правила было бы как ${from}: ${summary(parent.toggles).toLowerCase()}.`
: `Сейчас как ${from}. Измени что-нибудь, и у чата появится своё правило.`;
}
if (isFolder) {
return rule
? "Действует на чаты папки, у которых нет своего правила."
: "У папки нет правила, чаты идут по умолчаниям. Выбери набор, чтобы его создать.";
}
return "";
}
function differs(key: ToggleKey): boolean {
return (
rule !== null && parent !== null && rule[key] !== parent.toggles[key]
);
}
async function save(next: CaptureToggles) {
if (saving) {
return;
}
saving = true;
problem = null;
try {
rule = rule
? await updatePolicy(rule.id, next)
: await createPolicy({
...next,
scope_id: scopeId,
scope_type: scopeType,
});
onchange?.();
} catch {
problem = "Не удалось сохранить правило";
} finally {
saving = false;
}
}
function flip(key: ToggleKey) {
if (shown !== null) {
save({ ...pickToggles(shown), [key]: !shown[key] });
}
}
async function remove() {
if (rule === null || saving) {
return;
}
saving = true;
problem = null;
try {
await deletePolicy(rule.id);
rule = null;
onchange?.();
} catch {
problem = "Не удалось удалить правило";
} finally {
saving = false;
}
}
async function loadFiles() {
if (target === null) {
return;
}
try {
files = await getStorageScope(target);
} catch {
files = null;
}
}
async function moveFiles() {
if (target === null || moving) {
return;
}
moving = true;
problem = null;
try {
moved = await trashScope(target);
confirming = false;
await loadFiles();
} catch {
problem = "Не удалось переместить файлы в корзину";
} finally {
moving = false;
}
}
async function undoMove() {
if (moved === null || moving) {
return;
}
moving = true;
problem = null;
try {
await restoreFromTrash(moved.keys);
moved = null;
await loadFiles();
} catch {
problem = "Не удалось вернуть файлы";
} finally {
moving = false;
}
}
function where(scope: StorageScope): string {
return isFolder
? `в ${scope.chats} ${plural(scope.chats, "чате", "чатах", "чатах")} папки`
: "в этом чате";
}
onMount(async () => {
rule = initialRule ?? null;
parent = initialParent ?? null;
if (target === null) {
return;
}
if (initialRule === undefined) {
loading = true;
try {
({ rule, parent } = await getPolicyScope(target));
} catch {
problem = "Не удалось загрузить правило";
} finally {
loading = false;
}
}
await loadFiles();
});
</script>
<div class="card">
{#if loading}
<div class="center"><Spinner /></div>
{:else}
{#if hint}
<p class="hint">{hint}</p>
{/if}
<div class="presets">
{#each PRESETS as item (item.label)}
<button
aria-pressed={shown !== null && sameToggles(shown, item.toggles)}
class="preset"
disabled={saving}
onclick={() => save(item.toggles)}
type="button"
class:on={shown !== null && sameToggles(shown, item.toggles)}
>
{item.label}
</button>
{/each}
</div>
{#if shown}
<div class="toggles">
{#each TOGGLE_KEYS as key (key)}
<button
aria-checked={shown[key]}
class="toggle"
disabled={saving}
onclick={() => flip(key)}
role="switch"
type="button"
class:changed={differs(key)}
>
<span class="label">{TOGGLE_LABELS[key]}</span>
<span class="switch" class:on={shown[key]}>
<span class="knob"></span>
</span>
</button>
{/each}
</div>
{/if}
{#if problem}
<p class="problem">{problem}</p>
{/if}
{#if moved && moved.files > 0}
<div class="files">
<div class="files-text">
<span>{filesLabel(moved.files)} в корзине</span>
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small>
</div>
<Button disabled={moving} onclick={undoMove} smaller variant="text">
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
</Button>
</div>
{:else if files && files.files > 0}
<div class="files">
{#if confirming}
<div class="files-text">
<span>
{filesLabel(files.files)}, {formatBytes(files.bytes)}, уйдут в
корзину и перестанут скачиваться сами.
</span>
{#if files.gone > 0}
<small class="warn">
{files.gone}
из них потом не скачать заново
</small>
{/if}
<div class="files-actions">
<Button
onclick={() => {
confirming = false;
}}
smaller
variant="text"
>
Отмена
</Button>
<Button
disabled={moving}
loading={moving}
onclick={moveFiles}
pill
smaller
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
</Button>
</div>
</div>
{:else}
<div class="files-text">
<span>{formatBytes(files.bytes)} уже скачано</span>
<small>{filesLabel(files.files)} {where(files)}</small>
{#if files.kept > 0}
<small>
Ещё {filesLabel(files.kept)} есть и в других чатах, они
останутся
</small>
{/if}
</div>
<Button
onclick={() => {
confirming = true;
}}
pill
smaller
variant="gray"
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
</Button>
{/if}
</div>
{/if}
{#if rule && (isChat || isFolder)}
<button class="remove" disabled={saving} onclick={remove} type="button">
Удалить правило
</button>
{/if}
{/if}
</div>
<style lang="scss">
.card {
padding: 0.25rem 0 0.5rem;
}
.center {
display: flex;
justify-content: center;
padding: 1.5rem 0;
}
.hint {
margin: 0;
padding: 0.5rem 1rem 0.25rem;
font-size: 0.8125rem;
line-height: 1.4;
color: var(--color-text-secondary);
}
.presets {
display: flex;
gap: 0.25rem;
margin: 0.5rem 1rem 0.25rem;
padding: 0.125rem;
border-radius: 0.625rem;
background-color: var(--color-background-secondary);
}
.preset {
cursor: pointer;
flex: 1;
height: 2rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.8125rem;
color: var(--color-text-secondary);
white-space: nowrap;
background: none;
transition:
background-color 0.15s ease-out,
color 0.15s ease-out;
&.on {
color: var(--color-text);
background-color: var(--color-chat-hover);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (pointer: coarse) {
height: 2.5rem;
}
}
.toggles {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 0.5rem;
padding: 0.25rem 0.5rem;
}
.toggle {
cursor: pointer;
display: flex;
gap: 0.5rem;
align-items: center;
min-width: 0;
height: 2.75rem;
padding: 0 0.5rem;
border: 0;
border-radius: 0.625rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-text);
text-align: start;
background: none;
&.changed .label {
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
&:disabled {
cursor: default;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:not(:disabled):hover {
background-color: var(--color-chat-hover);
}
}
}
.label {
overflow: hidden;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.switch {
display: flex;
flex-shrink: 0;
align-items: center;
width: 2.125rem;
height: 1.25rem;
padding: 0.125rem;
border-radius: 0.625rem;
background-color: var(--color-borders-input);
transition: background-color 0.15s ease-out;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1rem;
height: 1rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease-out;
.on & {
transform: translateX(0.875rem);
}
}
.problem {
margin: 0;
padding: 0.25rem 1rem;
font-size: 0.8125rem;
color: var(--color-error);
}
.files {
display: flex;
gap: 0.5rem;
align-items: center;
margin: 0.5rem 0.75rem 0.25rem;
padding: 0.625rem 0.5rem 0.625rem 0.875rem;
border-radius: 0.75rem;
background-color: var(--color-background-secondary);
:global(.Button) {
gap: 0.375rem;
padding-inline: 0.875rem;
}
}
.files-text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
small {
font-size: 0.8125rem;
color: var(--color-text-secondary);
&.warn {
color: var(--color-storage-warning);
}
}
}
.files-actions {
display: flex;
gap: 0.25rem;
justify-content: flex-end;
margin-top: 0.375rem;
}
.remove {
cursor: pointer;
display: block;
margin: 0.25rem 0.5rem 0;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-error);
background: none;
&:focus-visible {
outline: 2px solid var(--color-error);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
</style>
@@ -0,0 +1,62 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { policyUi } from "#lib/stores/policy.svelte.js";
const target = $derived(policyUi.target);
const isChat = $derived(target !== null && "chatId" in target);
const scopeId = $derived.by(() => {
if (target === null) {
return null;
}
return "chatId" in target ? target.chatId : target.folderId;
});
</script>
<Dialog.Root bind:open={policyUi.open}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<div class="heading">
<Dialog.Title class="dialog-title">{policyUi.title}</Dialog.Title>
<span class="kind">
{isChat ? "Политика захвата чата" : "Политика захвата папки"}
</span>
</div>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="body custom-scroll">
{#key target}
<PolicyCard
onchange={() => policyUi.touch()}
{scopeId}
scopeType={isChat ? "chat" : "folder"}
/>
{/key}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.heading {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.kind {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.body {
overflow-y: auto;
padding-bottom: 0.5rem;
}
</style>
@@ -1,241 +1,333 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { import {
createPolicy,
deletePolicy, deletePolicy,
getPolicyOverview,
listFolders, listFolders,
listPolicies,
updatePolicy,
} from "#lib/api/endpoints.js"; } from "#lib/api/endpoints.js";
import type { import type {
CaptureToggles, CaptureToggles,
Folder, Folder,
PolicyRecord, PolicyRule,
PolicyScopeType, PolicyScopeType,
} from "#lib/api/types.js"; } from "#lib/api/types.js";
import CaptureToggleList from "#lib/components/policy/CaptureToggleList.svelte"; import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { accounts } from "#lib/stores/accounts.svelte.js"; import {
type Chip,
DEFAULT_LABELS,
diffChips,
sameToggles,
summary,
} from "#lib/format/policy.js";
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js"; import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
let policies = $state<PolicyRecord[]>([]); interface Draft {
scopeId: number;
scopeType: "folder" | "chat";
title: string;
}
const DEFAULT_ORDER: PolicyScopeType[] = [
"default_dm",
"default_group",
"default_channel",
];
const DEFAULT_ICONS: Partial<Record<PolicyScopeType, string>> = {
default_channel: "channel",
default_dm: "user",
default_group: "group",
};
let rules = $state<PolicyRule[]>([]);
let folders = $state<Folder[]>([]); let folders = $state<Folder[]>([]);
let loading = $state(true); let loading = $state(true);
let pickFolder = $state(false); let failed = $state(false);
let pickChat = $state(false); let problem = $state<string | null>(null);
let expanded = $state<string | null>(null);
let draft = $state<Draft | null>(null);
let picking = $state<"folder" | "chat" | null>(null);
let chatFilter = $state(""); let chatFilter = $state("");
const DEFAULT_SCOPES: { label: string; scope: PolicyScopeType }[] = [ const picker = createChatPicker();
{ label: "Личные чаты", scope: "default_dm" },
{ label: "Группы", scope: "default_group" },
{ label: "Каналы", scope: "default_channel" },
];
const ALL_FALSE: CaptureToggles = {
backfill: false,
media: false,
messages: false,
presence: false,
profile_history: false,
reactions: false,
self_destruct_media: false,
stories: false,
stt: false,
track_edits_deletes: false,
};
function pickToggles(t: CaptureToggles): CaptureToggles {
return {
backfill: t.backfill,
media: t.media,
messages: t.messages,
presence: t.presence,
profile_history: t.profile_history,
reactions: t.reactions,
self_destruct_media: t.self_destruct_media,
stories: t.stories,
stt: t.stt,
track_edits_deletes: t.track_edits_deletes,
};
}
const defaults = $derived( const defaults = $derived(
DEFAULT_SCOPES.map((d) => ({ DEFAULT_ORDER.map((scope) =>
label: d.label, rules.find((rule) => rule.scope_type === scope)
record: policies.find((p) => p.scope_type === d.scope), ).filter((rule): rule is PolicyRule => rule !== undefined)
})).filter((d): d is { label: string; record: PolicyRecord } =>
Boolean(d.record)
)
); );
const folderPolicies = $derived( const defaultBases = $derived<CaptureToggles[]>(defaults);
policies.filter((p) => p.scope_type === "folder") const folderRules = $derived(
rules
.filter((rule) => rule.scope_type === "folder")
.toSorted((a, b) => folderOrder(a.scope_id) - folderOrder(b.scope_id))
); );
const chatPolicies = $derived( const chatRules = $derived(
policies.filter((p) => p.scope_type === "chat") rules
.filter((rule) => rule.scope_type === "chat")
.toSorted((a, b) => title(a).localeCompare(title(b), "ru"))
); );
const availableFolders = $derived( const freeFolders = $derived(
folders.filter( folders.filter(
(f) => !folderPolicies.some((p) => p.scope_id === f.folder_id) (folder) =>
!folderRules.some((rule) => rule.scope_id === folder.folder_id)
) )
); );
const picker = createChatPicker(); const freeChats = $derived(
const availableChats = $derived(
picker.results.filter( picker.results.filter(
(c) => !chatPolicies.some((p) => p.scope_id === c.chat_id) (chat) => !chatRules.some((rule) => rule.scope_id === chat.chat_id)
) )
); );
$effect(() => { function folderOrder(id: number | null): number {
picker.search(chatFilter); return folders.find((folder) => folder.folder_id === id)?.order_index ?? 0;
});
$effect(() => {
chats.ensureMany(
chatPolicies
.map((policy) => policy.scope_id)
.filter((id): id is number => id !== null)
);
});
function folderTitle(id: number | null): string {
return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`;
} }
function chatTitle(id: number | null): string { function keyOf(scopeType: PolicyScopeType, scopeId: number | null): string {
return chats.byId(id ?? 0)?.title ?? `Чат ${id}`; return `${scopeType}:${scopeId}`;
}
function title(rule: PolicyRule): string {
if (rule.scope_type === "chat") {
return rule.title ?? `Чат ${rule.scope_id}`;
}
if (rule.scope_type === "folder") {
return rule.title ?? `Папка ${rule.scope_id}`;
}
return DEFAULT_LABELS[rule.scope_type] ?? "";
}
function redundant(rule: PolicyRule): boolean {
return rule.parent !== null && sameToggles(rule, rule.parent.toggles);
}
function chips(rule: PolicyRule): Chip[] {
return diffChips(rule, rule.parent ? [rule.parent.toggles] : defaultBases);
} }
async function reload() { async function reload() {
[policies, folders] = await Promise.all([listPolicies(), listFolders()]); try {
} [rules, folders] = await Promise.all([
getPolicyOverview(),
async function toggle( listFolders(),
record: PolicyRecord, ]);
key: keyof CaptureToggles, failed = false;
value: boolean if (
draft &&
rules.some(
(rule) =>
rule.scope_type === draft?.scopeType &&
rule.scope_id === draft?.scopeId
)
) { ) {
const next = { ...pickToggles(record), [key]: value }; expanded = keyOf(draft.scopeType, draft.scopeId);
policies = policies.map((p) => draft = null;
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 { } catch {
toasts.error("Не удалось сохранить политику"); failed = true;
await reload();
}
}
async function addOverride(scope: "folder" | "chat", scopeId: number) {
try {
const record = await createPolicy({
...ALL_FALSE,
scope_id: scopeId,
scope_type: scope,
});
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 { } finally {
loading = false; loading = false;
} }
} }
$effect(() => { function toggle(rule: PolicyRule) {
load(accounts.selectedId); const key = keyOf(rule.scope_type, rule.scope_id);
expanded = expanded === key ? null : key;
}
async function drop(rule: PolicyRule) {
problem = null;
try {
await deletePolicy(rule.id);
await reload();
} catch {
problem = "Не удалось убрать правило";
}
}
function start(scopeType: "folder" | "chat", scopeId: number, name: string) {
draft = { scopeId, scopeType, title: name };
picking = null;
chatFilter = "";
expanded = null;
}
function togglePicker(kind: "folder" | "chat") {
picking = picking === kind ? null : kind;
if (picking === "chat") {
picker.search(chatFilter);
}
}
onMount(() => {
chats.load();
reload();
}); });
</script> </script>
{#snippet card( {#snippet lead(
record: PolicyRecord, rule: PolicyRule
title: string,
onremove?: () => void
)} )}
<div class="card"> {#if rule.scope_type === "chat" && rule.scope_id !== null}
<div class="card-head"> <Avatar
<span class="card-title">{title}</span> avatar={{ id: rule.scope_id, kind: rule.scope_id > 0 ? "peer" : "chat" }}
{#if record.account_id === null} colorKey={rule.scope_id}
<span class="shared">для всех аккаунтов</span> hasAvatar={chats.byId(rule.scope_id)?.has_avatar ?? false}
name={title(rule)}
size={2.25}
/>
{:else}
<span class="glyph">
<Icon name={DEFAULT_ICONS[rule.scope_type] ?? "folder"} size="1.5rem" />
</span>
{/if} {/if}
{#if onremove && record.account_id !== null} {/snippet}
{#snippet row(
rule: PolicyRule,
plain: boolean
)}
{@const key = keyOf(rule.scope_type, rule.scope_id)}
{@const open = expanded === key}
{@const extra = !plain && redundant(rule)}
<div class="rule" class:open>
<div class="line">
<button <button
aria-label="Удалить оверрайд" aria-expanded={open}
class="remove" class="main"
onclick={onremove} onclick={() => toggle(rule)}
type="button" type="button"
> >
<Icon name="close" size="1.125rem" /> {@render lead(rule)}
<span class="text">
<span class="name">{title(rule)}</span>
{#if plain}
<span class="sub">{summary(rule)}</span>
{:else if extra}
<span class="sub">Ничего не меняет</span>
{:else}
<span class="chips">
{#each chips(rule) as chip (chip.sign + chip.label)}
<span class="chip {chip.sign}">
{chip.sign === "plus" ? "+" : "−"}
{chip.label}
</span>
{/each}
</span>
{/if}
</span>
{#if !extra}
<Icon name={open ? "up" : "down"} size="1.125rem" />
{/if}
</button> </button>
{#if extra}
<Button onclick={() => drop(rule)} smaller variant="text">
Убрать
</Button>
{/if} {/if}
</div> </div>
<CaptureToggleList {#if open}
onchange={(key, value) => toggle(record, key, value)} <div class="editor">
toggles={record} <PolicyCard
onchange={reload}
parent={rule.parent}
{rule}
scopeId={rule.scope_id}
scopeType={rule.scope_type}
/> />
</div> </div>
{/if}
</div>
{/snippet}
{#snippet pending(
item: Draft
)}
<div class="rule open">
<div class="line">
<div class="main static">
<span class="glyph">
<Icon
name={item.scopeType === "folder" ? "folder" : "user"}
size="1.5rem"
/>
</span>
<span class="text">
<span class="name">{item.title}</span>
<span class="sub">Новое правило</span>
</span>
</div>
<Button
onclick={() => {
draft = null;
}}
smaller
variant="text"
>
Отмена
</Button>
</div>
<div class="editor">
<PolicyCard
onchange={reload}
scopeId={item.scopeId}
scopeType={item.scopeType}
/>
</div>
</div>
{/snippet} {/snippet}
{#if loading} {#if loading}
<div class="state"><Spinner /></div> <div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>Не удалось загрузить политику.</p>
<Button onclick={reload} smaller variant="text">Повторить</Button>
</div>
{:else} {:else}
<div class="editor"> <div class="policy">
{#if problem}
<p class="problem">{problem}</p>
{/if}
<section> <section>
<div class="section-title">По умолчанию</div> <div class="section-title"><h3>По умолчанию</h3></div>
{#each defaults as item (item.record.id)} {#each defaults as rule (rule.id)}
{@render card(item.record, item.label)} {@render row(rule, true)}
{/each} {/each}
</section> </section>
<section> <section>
<div class="section-title">Оверрайды папок</div> <div class="section-title">
{#each folderPolicies as record (record.id)} <h3>Папки</h3>
{@render card(record, folderTitle(record.scope_id), () => <span>что меняют · побеждает первая подходящая</span>
removeOverride(record) </div>
)} {#if draft?.scopeType === "folder"}
{@render pending(draft)}
{/if}
{#each folderRules as rule (rule.id)}
{@render row(rule, false)}
{/each} {/each}
{#if availableFolders.length > 0} {#if freeFolders.length > 0}
<button <button
aria-expanded={picking === "folder"}
class="add" class="add"
onclick={() => { onclick={() => togglePicker("folder")}
pickFolder = !pickFolder;
}}
type="button" type="button"
> >
<Icon name="add" size="1.25rem" /> <Icon name="add" size="1.25rem" />
<span>Добавить папку</span> <span>Правило для папки</span>
</button> </button>
{#if pickFolder} {#if picking === "folder"}
<div class="picker"> <div class="picker">
{#each availableFolders as folder (folder.folder_id)} {#each freeFolders as folder (folder.folder_id)}
<button <button
class="pick-row" class="pick"
onclick={() => addOverride("folder", folder.folder_id)} onclick={() => start("folder", folder.folder_id, folder.title)}
type="button" type="button"
> >
{folder.title} {folder.title}
@@ -247,34 +339,44 @@
</section> </section>
<section> <section>
<div class="section-title">Оверрайды чатов</div> <div class="section-title">
{#each chatPolicies as record (record.id)} <h3>Чаты</h3>
{@render card(record, chatTitle(record.scope_id), () => <span>что меняют · важнее папок</span>
removeOverride(record) </div>
)} {#if draft?.scopeType === "chat"}
{@render pending(draft)}
{/if}
{#each chatRules as rule (rule.id)}
{@render row(rule, false)}
{/each} {/each}
<button <button
aria-expanded={picking === "chat"}
class="add" class="add"
onclick={() => { onclick={() => togglePicker("chat")}
pickChat = !pickChat;
}}
type="button" type="button"
> >
<Icon name="add" size="1.25rem" /> <Icon name="add" size="1.25rem" />
<span>Добавить чат</span> <span>Правило для чата</span>
</button> </button>
{#if pickChat} {#if picking === "chat"}
<div class="picker"> <div class="picker">
<input <input
aria-label="Поиск чата"
class="filter" class="filter"
oninput={() => picker.search(chatFilter)}
placeholder="Поиск чата" placeholder="Поиск чата"
type="text" type="text"
bind:value={chatFilter} bind:value={chatFilter}
> >
{#each availableChats as chat (chat.chat_id)} {#each freeChats as chat (chat.chat_id)}
<button <button
class="pick-row" class="pick"
onclick={() => addOverride("chat", chat.chat_id)} onclick={() =>
start(
"chat",
chat.chat_id,
chat.title ?? `Чат ${chat.chat_id}`
)}
type="button" type="button"
> >
{chat.title ?? `Чат ${chat.chat_id}`} {chat.title ?? `Чат ${chat.chat_id}`}
@@ -289,81 +391,192 @@
<style lang="scss"> <style lang="scss">
.state { .state {
display: flex; display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center; align-items: center;
justify-content: center;
padding: 2rem; padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
p {
margin: 0;
}
} }
.editor { .policy {
padding-bottom: 1rem; padding-bottom: 1rem;
} }
section { .problem {
padding: 0.5rem 0; margin: 0;
border-bottom: 1px solid var(--color-borders); padding: 0.75rem 1rem 0;
font-size: 0.8125rem;
color: var(--color-error);
} }
.section-title { .section-title {
padding: 0.5rem 1rem; display: flex;
gap: 0.75rem;
align-items: baseline;
justify-content: space-between;
padding: 0.875rem 1rem 0.375rem;
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--color-text-secondary);
h3 {
margin: 0;
font-size: inherit;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--color-primary); color: var(--color-primary);
} }
.card { span {
padding: 0.25rem 0 0.5rem; overflow: hidden;
text-align: end;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
.card-head { .rule.open {
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); background-color: var(--color-chat-hover);
} }
.line {
display: flex;
align-items: center;
padding-right: 0.5rem;
}
.main {
cursor: pointer;
display: flex;
flex: 1;
gap: 0.75rem;
align-items: center;
min-width: 0;
min-height: 3.25rem;
padding: 0.4375rem 0.5rem 0.4375rem 1rem;
border: 0;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
:global(.icon) {
color: var(--color-text-secondary);
}
&.static {
cursor: default;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.glyph {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.1875rem;
min-width: 0;
}
.name {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.chip {
display: inline-flex;
align-items: center;
height: 1.375rem;
padding: 0 0.5rem;
border-radius: 0.6875rem;
font-size: 0.75rem;
white-space: nowrap;
&.plus {
color: var(--color-policy-plus);
background-color: var(--color-policy-plus-fill);
}
&.minus {
color: var(--color-policy-minus);
background-color: var(--color-policy-minus-fill);
}
}
.editor {
padding-bottom: 0.25rem;
} }
.add { .add {
cursor: pointer; cursor: pointer;
display: flex; display: flex;
gap: 0.75rem;
align-items: center; align-items: center;
gap: 1rem;
width: 100%; width: 100%;
padding: 0.625rem 1rem; height: 2.75rem;
padding: 0 1rem 0 1.5rem;
border: 0; border: 0;
font: inherit;
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-primary); color: var(--color-primary);
background-color: transparent; text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover { &:hover {
background-color: var(--color-chat-hover); background-color: var(--color-chat-hover);
} }
} }
}
.picker { .picker {
display: flex; display: flex;
@@ -375,10 +588,12 @@
margin: 0.25rem 0.5rem 0.5rem; margin: 0.25rem 0.5rem 0.5rem;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--color-borders); border: 1px solid var(--color-borders);
border-radius: var(--border-radius-default); border-radius: var(--border-radius-default-small);
font: inherit;
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-text); color: var(--color-text);
background-color: var(--color-background); background-color: var(--color-background);
&:focus { &:focus {
@@ -387,8 +602,9 @@
} }
} }
.pick-row { .pick {
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
@@ -396,15 +612,24 @@
border: 0; border: 0;
border-radius: 0.5rem; border-radius: 0.5rem;
font: inherit;
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-text);
text-align: start; text-align: start;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: var(--color-text);
background-color: transparent;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover { &:hover {
background-color: var(--color-chat-hover); background-color: var(--color-chat-hover);
} }
} }
}
</style> </style>
+148
View File
@@ -0,0 +1,148 @@
import type { CaptureToggles, PolicyScopeType } from "#lib/api/types.js";
import { plural } from "#lib/format/shares.js";
export type ToggleKey = keyof CaptureToggles;
export const TOGGLE_LABELS: Record<ToggleKey, string> = {
backfill: "Бэкфилл",
media: "Медиа",
messages: "Сообщения",
presence: "Присутствие",
profile_history: "История профиля",
reactions: "Реакции",
self_destruct_media: "Исчезающие",
stories: "Сторис",
stt: "Расшифровка",
track_edits_deletes: "Правки, удаления",
};
export const TOGGLE_KEYS: ToggleKey[] = [
"messages",
"media",
"self_destruct_media",
"stt",
"reactions",
"track_edits_deletes",
"profile_history",
"stories",
"presence",
"backfill",
];
export const DEFAULT_LABELS: Partial<Record<PolicyScopeType, string>> = {
default_channel: "Каналы",
default_dm: "Личные чаты",
default_group: "Группы",
};
const PARENT_LABELS: Partial<Record<PolicyScopeType, string>> = {
default_channel: "у каналов",
default_dm: "у личных чатов",
default_group: "у групп",
};
const SHOWN_CHIPS = 3;
const SHORT_LIST = 2;
export interface Chip {
label: string;
sign: "plus" | "minus";
}
export function pickToggles(source: CaptureToggles): CaptureToggles {
return {
backfill: source.backfill,
media: source.media,
messages: source.messages,
presence: source.presence,
profile_history: source.profile_history,
reactions: source.reactions,
self_destruct_media: source.self_destruct_media,
stories: source.stories,
stt: source.stt,
track_edits_deletes: source.track_edits_deletes,
};
}
export function preset(only: ToggleKey[] | "all"): CaptureToggles {
const toggles = {} as CaptureToggles;
for (const key of TOGGLE_KEYS) {
toggles[key] = only === "all" || only.includes(key);
}
return toggles;
}
export function sameToggles(a: CaptureToggles, b: CaptureToggles): boolean {
return TOGGLE_KEYS.every((key) => a[key] === b[key]);
}
export function summary(toggles: CaptureToggles): string {
const on = TOGGLE_KEYS.filter((key) => toggles[key]);
const off = TOGGLE_KEYS.filter((key) => !toggles[key]);
if (off.length === 0) {
return "Всё";
}
if (on.length === 0) {
return "Ничего";
}
if (on.length === 1 && on[0] === "messages") {
return "Только сообщения";
}
if (off.length <= SHORT_LIST) {
const names = off.map((key) => TOGGLE_LABELS[key].toLowerCase());
return `Всё, кроме: ${names.join(", ")}`;
}
const names = on.slice(0, SHORT_LIST).map((key) => TOGGLE_LABELS[key]);
const rest = on.length - names.length;
return rest > 0 ? `${names.join(", ")} и ещё ${rest}` : names.join(", ");
}
export function parentLabel(
scopeType: PolicyScopeType,
title: string | null
): string {
if (scopeType === "folder") {
return `у папки «${title ?? "без названия"}»`;
}
return PARENT_LABELS[scopeType] ?? "по умолчанию";
}
function settings(count: number): string {
return `${count} ${plural(count, "настройка", "настройки", "настроек")}`;
}
function collapse(
keys: ToggleKey[],
sign: Chip["sign"],
whole: string
): Chip[] {
if (keys.length === 0) {
return [];
}
if (keys.length > SHOWN_CHIPS) {
return [{ label: whole, sign }];
}
return keys.map((key) => ({ label: TOGGLE_LABELS[key], sign }));
}
export function diffChips(
rule: CaptureToggles,
bases: CaptureToggles[]
): Chip[] {
const plus = TOGGLE_KEYS.filter(
(key) => rule[key] && bases.some((base) => !base[key])
);
const minus = TOGGLE_KEYS.filter(
(key) => !rule[key] && bases.some((base) => base[key])
);
const allOn = TOGGLE_KEYS.every((key) => rule[key]);
const allOff = TOGGLE_KEYS.every((key) => !rule[key]);
return [
...collapse(plus, "plus", allOn ? "Всё включено" : settings(plus.length)),
...collapse(
minus,
"minus",
allOff ? "Всё выключено" : settings(minus.length)
),
];
}
+36
View File
@@ -0,0 +1,36 @@
import type { ScopeTarget } from "#lib/api/types.js";
function createPolicyUi() {
let open = $state(false);
let target = $state<ScopeTarget | null>(null);
let title = $state("");
let revision = $state(0);
return {
edit(next: ScopeTarget, name: string) {
target = next;
title = name;
open = true;
},
get open() {
return open;
},
set open(value: boolean) {
open = value;
},
get revision() {
return revision;
},
get target() {
return target;
},
get title() {
return title;
},
touch() {
revision += 1;
},
};
}
export const policyUi = createPolicyUi();
+4
View File
@@ -7,6 +7,10 @@ html.theme-dark {
--color-storage-warning: #fb8c00; --color-storage-warning: #fb8c00;
--color-storage-trash: #6c6c6c; --color-storage-trash: #6c6c6c;
--storage-tile-mix: 90%; --storage-tile-mix: 90%;
--color-policy-plus: #7fd6a4;
--color-policy-plus-fill: #199e7029;
--color-policy-minus: #ff8a80;
--color-policy-minus-fill: #e5393524;
--color-primary: #8774e1; --color-primary: #8774e1;
--color-primary-opacity: #8378db1e; --color-primary-opacity: #8378db1e;
--color-primary-opacity-hover: #8378db40; --color-primary-opacity-hover: #8378db40;
+5
View File
@@ -142,6 +142,11 @@
--color-storage-trash: #939799; --color-storage-trash: #939799;
--storage-tile-mix: 100%; --storage-tile-mix: 100%;
--color-policy-plus: #0d6b49;
--color-policy-plus-fill: #12805a24;
--color-policy-minus: #b3261e;
--color-policy-minus-fill: #e5393524;
--color-yellow: #{$color-yellow}; --color-yellow: #{$color-yellow};
--color-orange: #{$color-orange}; --color-orange: #{$color-orange};
+3
View File
@@ -3,6 +3,7 @@
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import ChatList from "#lib/components/ChatList.svelte"; import ChatList from "#lib/components/ChatList.svelte";
import FolderTabs from "#lib/components/FolderTabs.svelte"; import FolderTabs from "#lib/components/FolderTabs.svelte";
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
import RightColumn from "#lib/components/RightColumn.svelte"; import RightColumn from "#lib/components/RightColumn.svelte";
import SearchInput from "#lib/components/search/SearchInput.svelte"; import SearchInput from "#lib/components/search/SearchInput.svelte";
import SearchResults from "#lib/components/search/SearchResults.svelte"; import SearchResults from "#lib/components/search/SearchResults.svelte";
@@ -101,6 +102,8 @@
{/if} {/if}
</div> </div>
<PolicyDialog />
<ShareDialog <ShareDialog
label={shareUi.label} label={shareUi.label}
onchange={() => shareUi.touch()} onchange={() => shareUi.touch()}