Files
beavergram/frontend/src/lib/components/policy/PolicyEditor.svelte
T

636 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>