636 lines
14 KiB
Svelte
636 lines
14 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from "svelte";
|
||
import {
|
||
deletePolicy,
|
||
getPolicyOverview,
|
||
listFolders,
|
||
} from "#lib/api/endpoints.js";
|
||
import type {
|
||
CaptureToggles,
|
||
Folder,
|
||
PolicyRule,
|
||
PolicyScopeType,
|
||
} from "#lib/api/types.js";
|
||
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 Spinner from "#lib/components/ui/Spinner.svelte";
|
||
import {
|
||
type Chip,
|
||
DEFAULT_LABELS,
|
||
diffChips,
|
||
sameToggles,
|
||
summary,
|
||
} from "#lib/format/policy.js";
|
||
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
|
||
import { chats } from "#lib/stores/chats.svelte.js";
|
||
|
||
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 loading = $state(true);
|
||
let failed = $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("");
|
||
|
||
const picker = createChatPicker();
|
||
|
||
const defaults = $derived(
|
||
DEFAULT_ORDER.map((scope) =>
|
||
rules.find((rule) => rule.scope_type === scope)
|
||
).filter((rule): rule is PolicyRule => rule !== undefined)
|
||
);
|
||
const defaultBases = $derived<CaptureToggles[]>(defaults);
|
||
const folderRules = $derived(
|
||
rules
|
||
.filter((rule) => rule.scope_type === "folder")
|
||
.toSorted((a, b) => folderOrder(a.scope_id) - folderOrder(b.scope_id))
|
||
);
|
||
const chatRules = $derived(
|
||
rules
|
||
.filter((rule) => rule.scope_type === "chat")
|
||
.toSorted((a, b) => title(a).localeCompare(title(b), "ru"))
|
||
);
|
||
const freeFolders = $derived(
|
||
folders.filter(
|
||
(folder) =>
|
||
!folderRules.some((rule) => rule.scope_id === folder.folder_id)
|
||
)
|
||
);
|
||
const freeChats = $derived(
|
||
picker.results.filter(
|
||
(chat) => !chatRules.some((rule) => rule.scope_id === chat.chat_id)
|
||
)
|
||
);
|
||
|
||
function folderOrder(id: number | null): number {
|
||
return folders.find((folder) => folder.folder_id === id)?.order_index ?? 0;
|
||
}
|
||
|
||
function keyOf(scopeType: PolicyScopeType, scopeId: number | null): string {
|
||
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() {
|
||
try {
|
||
[rules, folders] = await Promise.all([
|
||
getPolicyOverview(),
|
||
listFolders(),
|
||
]);
|
||
failed = false;
|
||
if (
|
||
draft &&
|
||
rules.some(
|
||
(rule) =>
|
||
rule.scope_type === draft?.scopeType &&
|
||
rule.scope_id === draft?.scopeId
|
||
)
|
||
) {
|
||
expanded = keyOf(draft.scopeType, draft.scopeId);
|
||
draft = null;
|
||
}
|
||
} catch {
|
||
failed = true;
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
function toggle(rule: PolicyRule) {
|
||
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>
|
||
|
||
{#snippet lead(
|
||
rule: PolicyRule
|
||
)}
|
||
{#if rule.scope_type === "chat" && rule.scope_id !== null}
|
||
<Avatar
|
||
avatar={{ id: rule.scope_id, kind: rule.scope_id > 0 ? "peer" : "chat" }}
|
||
colorKey={rule.scope_id}
|
||
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}
|
||
{/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
|
||
aria-expanded={open}
|
||
class="main"
|
||
onclick={() => toggle(rule)}
|
||
type="button"
|
||
>
|
||
{@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>
|
||
{#if extra}
|
||
<Button onclick={() => drop(rule)} smaller variant="text">
|
||
Убрать
|
||
</Button>
|
||
{/if}
|
||
</div>
|
||
{#if open}
|
||
<div class="editor">
|
||
<PolicyCard
|
||
onchange={reload}
|
||
parent={rule.parent}
|
||
{rule}
|
||
scopeId={rule.scope_id}
|
||
scopeType={rule.scope_type}
|
||
/>
|
||
</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}
|
||
|
||
{#if loading}
|
||
<div class="state"><Spinner /></div>
|
||
{:else if failed}
|
||
<div class="state">
|
||
<p>Не удалось загрузить политику.</p>
|
||
<Button onclick={reload} smaller variant="text">Повторить</Button>
|
||
</div>
|
||
{:else}
|
||
<div class="policy">
|
||
{#if problem}
|
||
<p class="problem">{problem}</p>
|
||
{/if}
|
||
|
||
<section>
|
||
<div class="section-title"><h3>По умолчанию</h3></div>
|
||
{#each defaults as rule (rule.id)}
|
||
{@render row(rule, true)}
|
||
{/each}
|
||
</section>
|
||
|
||
<section>
|
||
<div class="section-title">
|
||
<h3>Папки</h3>
|
||
<span>что меняют · побеждает первая подходящая</span>
|
||
</div>
|
||
{#if draft?.scopeType === "folder"}
|
||
{@render pending(draft)}
|
||
{/if}
|
||
{#each folderRules as rule (rule.id)}
|
||
{@render row(rule, false)}
|
||
{/each}
|
||
{#if freeFolders.length > 0}
|
||
<button
|
||
aria-expanded={picking === "folder"}
|
||
class="add"
|
||
onclick={() => togglePicker("folder")}
|
||
type="button"
|
||
>
|
||
<Icon name="add" size="1.25rem" />
|
||
<span>Правило для папки</span>
|
||
</button>
|
||
{#if picking === "folder"}
|
||
<div class="picker">
|
||
{#each freeFolders as folder (folder.folder_id)}
|
||
<button
|
||
class="pick"
|
||
onclick={() => start("folder", folder.folder_id, folder.title)}
|
||
type="button"
|
||
>
|
||
{folder.title}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</section>
|
||
|
||
<section>
|
||
<div class="section-title">
|
||
<h3>Чаты</h3>
|
||
<span>что меняют · важнее папок</span>
|
||
</div>
|
||
{#if draft?.scopeType === "chat"}
|
||
{@render pending(draft)}
|
||
{/if}
|
||
{#each chatRules as rule (rule.id)}
|
||
{@render row(rule, false)}
|
||
{/each}
|
||
<button
|
||
aria-expanded={picking === "chat"}
|
||
class="add"
|
||
onclick={() => togglePicker("chat")}
|
||
type="button"
|
||
>
|
||
<Icon name="add" size="1.25rem" />
|
||
<span>Правило для чата</span>
|
||
</button>
|
||
{#if picking === "chat"}
|
||
<div class="picker">
|
||
<input
|
||
aria-label="Поиск чата"
|
||
class="filter"
|
||
oninput={() => picker.search(chatFilter)}
|
||
placeholder="Поиск чата"
|
||
type="text"
|
||
bind:value={chatFilter}
|
||
>
|
||
{#each freeChats as chat (chat.chat_id)}
|
||
<button
|
||
class="pick"
|
||
onclick={() =>
|
||
start(
|
||
"chat",
|
||
chat.chat_id,
|
||
chat.title ?? `Чат ${chat.chat_id}`
|
||
)}
|
||
type="button"
|
||
>
|
||
{chat.title ?? `Чат ${chat.chat_id}`}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
</div>
|
||
{/if}
|
||
|
||
<style lang="scss">
|
||
.state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
align-items: center;
|
||
|
||
padding: 2rem 1rem;
|
||
|
||
font-size: 0.9375rem;
|
||
color: var(--color-text-secondary);
|
||
|
||
p {
|
||
margin: 0;
|
||
}
|
||
}
|
||
|
||
.policy {
|
||
padding-bottom: 1rem;
|
||
}
|
||
|
||
.problem {
|
||
margin: 0;
|
||
padding: 0.75rem 1rem 0;
|
||
font-size: 0.8125rem;
|
||
color: var(--color-error);
|
||
}
|
||
|
||
.section-title {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
|
||
padding: 0.875rem 1rem 0.375rem;
|
||
|
||
font-size: 0.8125rem;
|
||
color: var(--color-text-secondary);
|
||
|
||
h3 {
|
||
margin: 0;
|
||
font-size: inherit;
|
||
font-weight: var(--font-weight-medium);
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
span {
|
||
overflow: hidden;
|
||
text-align: end;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.rule.open {
|
||
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 {
|
||
cursor: pointer;
|
||
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
align-items: center;
|
||
|
||
width: 100%;
|
||
height: 2.75rem;
|
||
padding: 0 1rem 0 1.5rem;
|
||
border: 0;
|
||
|
||
font: inherit;
|
||
font-size: 0.9375rem;
|
||
color: var(--color-primary);
|
||
text-align: start;
|
||
|
||
background: none;
|
||
|
||
&:focus-visible {
|
||
outline: 2px solid var(--color-primary);
|
||
outline-offset: -2px;
|
||
}
|
||
|
||
@media (hover: hover) {
|
||
&: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-small);
|
||
|
||
font: inherit;
|
||
font-size: 0.9375rem;
|
||
color: var(--color-text);
|
||
|
||
background-color: var(--color-background);
|
||
|
||
&:focus {
|
||
border-color: var(--color-primary);
|
||
outline: none;
|
||
}
|
||
}
|
||
|
||
.pick {
|
||
cursor: pointer;
|
||
|
||
overflow: hidden;
|
||
|
||
width: 100%;
|
||
padding: 0.5rem 0.75rem;
|
||
border: 0;
|
||
border-radius: 0.5rem;
|
||
|
||
font: inherit;
|
||
font-size: 0.9375rem;
|
||
color: var(--color-text);
|
||
text-align: start;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
|
||
background: none;
|
||
|
||
&:focus-visible {
|
||
outline: 2px solid var(--color-primary);
|
||
outline-offset: -2px;
|
||
}
|
||
|
||
@media (hover: hover) {
|
||
&:hover {
|
||
background-color: var(--color-chat-hover);
|
||
}
|
||
}
|
||
}
|
||
</style>
|