chore: improve backup admin controls

This commit is contained in:
3252a8
2026-05-27 23:22:20 +03:00
parent ef4b493e65
commit b6c6887842
7 changed files with 393 additions and 93 deletions
@@ -6,6 +6,7 @@
AdminEmptyState, AdminEmptyState,
AdminSelect, AdminSelect,
} from "$components/patterns/admin/index.js"; } from "$components/patterns/admin/index.js";
import { Checkbox } from "$components/ui/index.js";
import { Switch } from "$components/ui/primitives.js"; import { Switch } from "$components/ui/primitives.js";
import { getContext, onDestroy, onMount } from "svelte"; import { getContext, onDestroy, onMount } from "svelte";
@@ -343,9 +344,8 @@
} }
} }
function toggleAdminTheme(event, theme) { function toggleAdminTheme(theme, checked) {
event.stopPropagation(); themesStore.toggleAdminUse(theme.key, checked);
themesStore.toggleAdminUse(theme.key, event.currentTarget.checked);
} }
function setThemeAccent(theme, value) { function setThemeAccent(theme, value) {
@@ -675,11 +675,11 @@
/> />
</label> </label>
<label class="admin-theme-card-option"> <label class="admin-theme-card-option">
<input <Checkbox
type="checkbox"
checked={theme.use_in_admin !== false} checked={theme.use_in_admin !== false}
disabled={themesSaving} disabled={themesSaving}
onchange={(event) => toggleAdminTheme(event, theme)} ariaLabel={at("themes_use_in_admin", {}, "Use in admin")}
onCheckedChange={(checked) => toggleAdminTheme(theme, checked)}
/> />
<span>{at("themes_use_in_admin", {}, "Использовать в админке")}</span> <span>{at("themes_use_in_admin", {}, "Использовать в админке")}</span>
</label> </label>
@@ -855,10 +855,6 @@
font-size: 13px; font-size: 13px;
} }
.admin-theme-card-option input[type="checkbox"] {
accent-color: var(--accent);
}
.admin-theme-grid { .admin-theme-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+135 -22
View File
@@ -4,9 +4,11 @@
AdminBadge, AdminBadge,
AdminButton, AdminButton,
AdminEmptyState, AdminEmptyState,
AdminPagination,
AdminTable, AdminTable,
AdminTableSkeleton, AdminTableSkeleton,
} from "$components/patterns/admin/index.js"; } from "$components/patterns/admin/index.js";
import { Checkbox, RadioGroup, RadioGroupItem } from "$components/ui/index.js";
import { import {
CheckCircle2, CheckCircle2,
Database, Database,
@@ -16,15 +18,18 @@
TriangleAlert, TriangleAlert,
Upload, Upload,
} from "$components/ui/icons.js"; } from "$components/ui/icons.js";
import { Tooltip } from "$components/ui/primitives.js";
export let at = (key) => key; export let at = (key) => key;
export let fmtDate = (value) => value; export let fmtDate = (value) => value;
const BACKUPS_PAGE_SIZE = 10;
const backupsStore = getContext("backupsStore"); const backupsStore = getContext("backupsStore");
let selectedName = ""; let selectedName = "";
let restoreDatabase = true; let restoreDatabase = true;
let restoreCompose = false; let restoreCompose = false;
let backupsPage = 0;
let fileInput = null; let fileInput = null;
$: ({ $: ({
@@ -36,9 +41,18 @@
backupsRestoring, backupsRestoring,
lastRestore, lastRestore,
} = $backupsStore); } = $backupsStore);
$: if (!selectedName && archives?.length) selectedName = archives[0].name; $: totalArchives = archives?.length || 0;
$: backupsPageCount = Math.max(1, Math.ceil(totalArchives / BACKUPS_PAGE_SIZE));
$: if (backupsPage > backupsPageCount - 1) backupsPage = backupsPageCount - 1;
$: backupsPageStart = backupsPage * BACKUPS_PAGE_SIZE;
$: pagedArchives = (archives || []).slice(backupsPageStart, backupsPageStart + BACKUPS_PAGE_SIZE);
$: if (!selectedName && archives?.length) {
selectedName = archives[0].name;
backupsPage = 0;
}
$: if (selectedName && archives?.length && !archives.some((item) => item.name === selectedName)) { $: if (selectedName && archives?.length && !archives.some((item) => item.name === selectedName)) {
selectedName = archives[0].name; selectedName = archives[0].name;
backupsPage = 0;
} }
$: selectedArchive = (archives || []).find((item) => item.name === selectedName) || null; $: selectedArchive = (archives || []).find((item) => item.name === selectedName) || null;
$: if (selectedArchive && restoreDatabase && !selectedArchive.has_database) $: if (selectedArchive && restoreDatabase && !selectedArchive.has_database)
@@ -82,17 +96,45 @@
return parts.join(" + "); return parts.join(" + ");
} }
function selectArchive(name) {
selectedName = name;
}
function focusArchivePage(name) {
const index = (archives || []).findIndex((item) => item.name === name);
if (index >= 0) backupsPage = Math.floor(index / BACKUPS_PAGE_SIZE);
}
function warningsText(warnings) {
return (warnings || []).filter(Boolean).join("\n");
}
function paginationMeta() {
const end = Math.min(backupsPageStart + pagedArchives.length, totalArchives);
return at(
"backups_pagination_meta",
{ from: backupsPageStart + 1, to: end, total: totalArchives },
`${backupsPageStart + 1}-${end} / ${totalArchives}`
);
}
async function uploadSelectedFile(event) { async function uploadSelectedFile(event) {
const file = event?.currentTarget?.files?.[0]; const file = event?.currentTarget?.files?.[0];
if (!file) return; if (!file) return;
const archive = await backupsStore.uploadArchive(file); const archive = await backupsStore.uploadArchive(file);
if (archive?.name) selectedName = archive.name; if (archive?.name) {
selectedName = archive.name;
focusArchivePage(archive.name);
}
event.currentTarget.value = ""; event.currentTarget.value = "";
} }
async function createManualBackup() { async function createManualBackup() {
const archive = await backupsStore.createBackup(); const archive = await backupsStore.createBackup();
if (archive?.name) selectedName = archive.name; if (archive?.name) {
selectedName = archive.name;
focusArchivePage(archive.name);
}
} }
async function restoreSelected() { async function restoreSelected() {
@@ -167,19 +209,19 @@
</header> </header>
<div class="admin-card-body backups-restore-body"> <div class="admin-card-body backups-restore-body">
<label class="backups-check" class:is-disabled={!selectedArchive?.has_database}> <label class="backups-check" class:is-disabled={!selectedArchive?.has_database}>
<input <Checkbox
type="checkbox"
bind:checked={restoreDatabase} bind:checked={restoreDatabase}
disabled={!selectedArchive?.has_database || backupsRestoring} disabled={!selectedArchive?.has_database || backupsRestoring}
ariaLabel={at("backups_target_database", {}, "БД")}
/> />
<Database size={16} /> <Database size={16} />
<span>{at("backups_target_database", {}, "БД")}</span> <span>{at("backups_target_database", {}, "БД")}</span>
</label> </label>
<label class="backups-check" class:is-disabled={!selectedArchive?.has_compose}> <label class="backups-check" class:is-disabled={!selectedArchive?.has_compose}>
<input <Checkbox
type="checkbox"
bind:checked={restoreCompose} bind:checked={restoreCompose}
disabled={!selectedArchive?.has_compose || backupsRestoring} disabled={!selectedArchive?.has_compose || backupsRestoring}
ariaLabel={at("backups_target_compose", {}, "compose-папка")}
/> />
<Server size={16} /> <Server size={16} />
<span>{at("backups_target_compose", {}, "compose-папка")}</span> <span>{at("backups_target_compose", {}, "compose-папка")}</span>
@@ -214,6 +256,12 @@
<span class="admin-muted">{at("backups_empty", {}, "Архивов пока нет")}</span> <span class="admin-muted">{at("backups_empty", {}, "Архивов пока нет")}</span>
</AdminEmptyState> </AdminEmptyState>
{:else} {:else}
<RadioGroup
class="backups-archive-radio-group"
name="backup-archive"
value={selectedName}
onValueChange={selectArchive}
>
<AdminTable class="backups-table"> <AdminTable class="backups-table">
<thead> <thead>
<tr> <tr>
@@ -226,16 +274,13 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each archives as archive (archive.name)} {#each pagedArchives as archive (archive.name)}
<tr class:is-selected={archive.name === selectedName}> <tr class:is-selected={archive.name === selectedName}>
<td data-label={at("select", {}, "Выбрать")}> <td data-label={at("select", {}, "Выбрать")}>
<input <RadioGroupItem
type="radio"
name="backup-archive"
value={archive.name} value={archive.name}
checked={archive.name === selectedName} ariaLabel={archive.name}
on:change={() => (selectedName = archive.name)} class="backups-radio"
aria-label={archive.name}
/> />
</td> </td>
<td <td
@@ -264,10 +309,22 @@
</td> </td>
<td data-label={at("backups_col_warnings", {}, "Предупреждения")}> <td data-label={at("backups_col_warnings", {}, "Предупреждения")}>
{#if archive.warnings?.length} {#if archive.warnings?.length}
<AdminBadge variant="warning"> <Tooltip.Root>
<Tooltip.Trigger
class="backups-warning-trigger"
aria-label={warningsText(archive.warnings)}
>
<TriangleAlert size={12} /> <TriangleAlert size={12} />
{archive.warnings.length} {archive.warnings.length}
</AdminBadge> </Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content class="backups-warning-tooltip" side="top" align="end">
{#each archive.warnings as warning, index}
<p>{index + 1}. {warning}</p>
{/each}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
{:else} {:else}
<span class="admin-muted">-</span> <span class="admin-muted">-</span>
{/if} {/if}
@@ -276,6 +333,18 @@
{/each} {/each}
</tbody> </tbody>
</AdminTable> </AdminTable>
</RadioGroup>
{#if totalArchives > BACKUPS_PAGE_SIZE}
<AdminPagination
meta={paginationMeta()}
prevLabel={at("pagination_prev", {}, "Назад")}
nextLabel={at("pagination_next", {}, "Далее")}
prevDisabled={backupsPage <= 0}
nextDisabled={backupsPage >= backupsPageCount - 1}
onPrev={() => (backupsPage = Math.max(0, backupsPage - 1))}
onNext={() => (backupsPage = Math.min(backupsPageCount - 1, backupsPage + 1))}
/>
{/if}
{/if} {/if}
</div> </div>
</div> </div>
@@ -335,12 +404,6 @@
font-size: 13px; font-size: 13px;
} }
.backups-check input {
width: 16px;
height: 16px;
margin: 0;
}
.backups-check.is-disabled { .backups-check.is-disabled {
opacity: 0.55; opacity: 0.55;
} }
@@ -362,6 +425,56 @@
gap: 6px; gap: 6px;
} }
:global(.backups-archive-radio-group.ui-radio-group) {
display: block;
}
:global(.backups-radio.ui-radio-item) {
margin-inline: auto;
}
:global(.backups-warning-trigger) {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 24px;
padding: 2px 7px;
border: 1px solid var(--warning-border);
border-radius: 999px;
background: var(--warning-soft);
color: var(--warning-text);
font: inherit;
font-size: 11px;
font-weight: 600;
cursor: help;
outline: none;
}
:global(.backups-warning-trigger:focus-visible) {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--warning) 22%, transparent);
}
:global(.backups-warning-tooltip) {
z-index: 120;
display: grid;
gap: 6px;
max-width: min(440px, calc(100vw - 32px));
padding: 10px 12px;
border: 1px solid var(--admin-border);
border-radius: 10px;
background: var(--admin-surface);
color: var(--admin-text);
box-shadow: var(--shadow-popover);
font-size: 12px;
line-height: 1.4;
}
:global(.backups-warning-tooltip) p {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 760px) { @media (max-width: 760px) {
.backups-restore-body { .backups-restore-body {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -1,5 +1,6 @@
<script> <script>
import { Label, Separator, Tabs } from "$components/ui/primitives.js"; import { Label, Separator, Tabs } from "$components/ui/primitives.js";
import { Checkbox } from "$components/ui/index.js";
import Dialog from "$components/ui/dialog.svelte"; import Dialog from "$components/ui/dialog.svelte";
import { import {
AdminBadge, AdminBadge,
@@ -680,8 +681,7 @@
<div class="admin-user-action-sheet-footer admin-override-card-footer"> <div class="admin-user-action-sheet-footer admin-override-card-footer">
<div class="admin-override-card-toolbar"> <div class="admin-override-card-toolbar">
<label class="admin-override-unlimited-label"> <label class="admin-override-unlimited-label">
<input <Checkbox
type="checkbox"
bind:checked={$usersStore.premiumUnlimitedDraft} bind:checked={$usersStore.premiumUnlimitedDraft}
aria-label={at("user_override_unlimited_short", {}, "Безлимит")} aria-label={at("user_override_unlimited_short", {}, "Безлимит")}
/> />
@@ -760,8 +760,7 @@
<div class="admin-user-action-sheet-footer admin-override-card-footer"> <div class="admin-user-action-sheet-footer admin-override-card-footer">
<div class="admin-override-card-toolbar"> <div class="admin-override-card-toolbar">
<label class="admin-override-unlimited-label"> <label class="admin-override-unlimited-label">
<input <Checkbox
type="checkbox"
bind:checked={$usersStore.regularUnlimitedDraft} bind:checked={$usersStore.regularUnlimitedDraft}
aria-label={at("user_override_unlimited_short", {}, "Безлимит")} aria-label={at("user_override_unlimited_short", {}, "Безлимит")}
/> />
@@ -1110,11 +1109,6 @@
user-select: none; user-select: none;
min-height: 36px; min-height: 36px;
} }
.admin-override-unlimited-label input[type="checkbox"] {
width: 18px;
height: 18px;
flex-shrink: 0;
}
@media (max-width: 520px) { @media (max-width: 520px) {
.admin-override-card-toolbar { .admin-override-card-toolbar {
flex-direction: column; flex-direction: column;
@@ -0,0 +1,78 @@
<script>
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Check } from "./icons.js";
export let checked = false;
export let disabled = false;
export let indeterminate = false;
export let ariaLabel = "";
export let onCheckedChange = () => {};
let className = "";
export { className as class };
function handleCheckedChange(next) {
checked = Boolean(next);
onCheckedChange(checked);
}
</script>
<CheckboxPrimitive.Root
class={cn("ui-checkbox", className)}
{checked}
{disabled}
{indeterminate}
aria-label={ariaLabel}
onCheckedChange={handleCheckedChange}
{...$$restProps}
>
{#if checked || indeterminate}
<Check size={13} strokeWidth={3} aria-hidden="true" />
{/if}
</CheckboxPrimitive.Root>
<style>
:global(.ui-checkbox) {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex: 0 0 18px;
padding: 0;
border: 1px solid var(--admin-border-strong, var(--border));
border-radius: 4px;
background: color-mix(
in srgb,
var(--admin-bg, var(--panel)) 84%,
var(--admin-surface-2, transparent)
);
color: #fff;
cursor: pointer;
outline: none;
transition:
background 0.14s ease,
border-color 0.14s ease,
box-shadow 0.14s ease;
}
:global(.ui-checkbox:hover) {
border-color: var(--admin-ring, var(--accent));
}
:global(.ui-checkbox[data-state="checked"]),
:global(.ui-checkbox[data-state="indeterminate"]) {
border-color: color-mix(in srgb, var(--accent) 82%, transparent);
background: var(--accent);
}
:global(.ui-checkbox:focus-visible) {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent);
}
:global(.ui-checkbox:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
</style>
+3
View File
@@ -1,9 +1,12 @@
export { default as AttentionDot } from "./attention-dot.svelte"; export { default as AttentionDot } from "./attention-dot.svelte";
export { default as Badge } from "./badge.svelte"; export { default as Badge } from "./badge.svelte";
export { default as Button } from "./button.svelte"; export { default as Button } from "./button.svelte";
export { default as Checkbox } from "./checkbox.svelte";
export { default as Dialog } from "./dialog.svelte"; export { default as Dialog } from "./dialog.svelte";
export { default as Input } from "./input.svelte"; export { default as Input } from "./input.svelte";
export { default as LegacyCard } from "./card.svelte"; export { default as LegacyCard } from "./card.svelte";
export { default as RadioGroup } from "./radio-group.svelte";
export { default as RadioGroupItem } from "./radio-group-item.svelte";
export { default as Skeleton } from "./skeleton.svelte"; export { default as Skeleton } from "./skeleton.svelte";
export { default as Spinner } from "./spinner.svelte"; export { default as Spinner } from "./spinner.svelte";
export { default as ScrollArea } from "./scroll-area.svelte"; export { default as ScrollArea } from "./scroll-area.svelte";
@@ -0,0 +1,80 @@
<script>
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
export let value = "";
export let disabled = false;
export let ariaLabel = "";
let className = "";
export { className as class };
</script>
<RadioGroupPrimitive.Item
class={cn("ui-radio-item", className)}
{value}
{disabled}
aria-label={ariaLabel || value}
{...$$restProps}
>
<span class="ui-radio-indicator" aria-hidden="true"></span>
<slot />
</RadioGroupPrimitive.Item>
<style>
:global(.ui-radio-item) {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex: 0 0 18px;
padding: 0;
border: 1px solid var(--admin-border-strong, var(--border));
border-radius: 999px;
background: color-mix(
in srgb,
var(--admin-bg, var(--panel)) 84%,
var(--admin-surface-2, transparent)
);
cursor: pointer;
outline: none;
transition:
border-color 0.14s ease,
box-shadow 0.14s ease;
}
:global(.ui-radio-item:hover) {
border-color: var(--admin-ring, var(--accent));
}
:global(.ui-radio-item:focus-visible) {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent);
}
:global(.ui-radio-item:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
.ui-radio-indicator {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--accent);
opacity: 0;
transform: scale(0.6);
transition:
opacity 0.14s ease,
transform 0.14s ease;
}
:global(.ui-radio-item[data-state="checked"]) {
border-color: var(--accent);
}
:global(.ui-radio-item[data-state="checked"]) .ui-radio-indicator {
opacity: 1;
transform: scale(1);
}
</style>
@@ -0,0 +1,36 @@
<script>
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
export let value = "";
export let orientation = "vertical";
export let name = "";
export let disabled = false;
export let onValueChange = () => {};
let className = "";
export { className as class };
function handleValueChange(next) {
value = next;
onValueChange(next);
}
</script>
<RadioGroupPrimitive.Root
class={cn("ui-radio-group", className)}
{value}
{orientation}
{name}
{disabled}
onValueChange={handleValueChange}
{...$$restProps}
>
<slot />
</RadioGroupPrimitive.Root>
<style>
:global(.ui-radio-group) {
display: grid;
gap: 8px;
}
</style>