refactor(frontend): storage panel answers in place instead of toasts, empty groups hidden, weight bar animates by transform

This commit is contained in:
hh committed 2026-10-14 03:14:51 +02:00
1 parent 55634984fd
commit 06ae3e928f
4 files changed
+79 -31

No files matched your search

@@ -31,7 +31,6 @@
messagesLabel, messagesLabel,
} from "#lib/format/storage.js"; } from "#lib/format/storage.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";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js"; import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -47,6 +46,7 @@
let done = $state(false); let done = $state(false);
let busy = $state(false); let busy = $state(false);
let undo = $state<TrashOutcome | null>(null); let undo = $state<TrashOutcome | null>(null);
let problem = $state<string | null>(null);
let viewerOpen = $state(false); let viewerOpen = $state(false);
let viewerIndex = $state(0); let viewerIndex = $state(0);
const selected = new SvelteSet<number>(); const selected = new SvelteSet<number>();
@@ -61,8 +61,9 @@
const chosenGone = $derived( const chosenGone = $derived(
chosen.filter((file) => !file.refetchable).length chosen.filter((file) => !file.refetchable).length
); );
const segments = $derived( const groups = $derived(
summary?.groups.filter((item) => item.bytes > 0) ?? [] summary?.groups.filter((item) => item.files > 0 || item.group === group) ??
[]
); );
const viewerItems = $derived<ViewerItem[]>( const viewerItems = $derived<ViewerItem[]>(
files.map((file) => ({ files.map((file) => ({
@@ -133,6 +134,7 @@
function toggle(mediaId: number) { function toggle(mediaId: number) {
hideUndo(); hideUndo();
problem = null;
if (!selected.delete(mediaId)) { if (!selected.delete(mediaId)) {
selected.add(mediaId); selected.add(mediaId);
} }
@@ -156,6 +158,7 @@
return; return;
} }
busy = true; busy = true;
problem = null;
try { try {
const outcome = await moveToTrash(mediaIds); const outcome = await moveToTrash(mediaIds);
const moved = new Set( const moved = new Set(
@@ -170,11 +173,11 @@
selected.clear(); selected.clear();
showUndo(outcome); showUndo(outcome);
if (outcome.blocked.length > 0) { if (outcome.blocked.length > 0) {
toasts.error("Файл занят в сторис или аватаре и остался на диске"); problem = "Файл занят в сторис или аватаре и остался на диске";
} }
await loadSummary(); await loadSummary();
} catch { } catch {
toasts.error("Не удалось переместить в корзину"); problem = "Не удалось переместить в корзину";
} finally { } finally {
busy = false; busy = false;
} }
@@ -190,7 +193,7 @@
await restoreFromTrash(keys); await restoreFromTrash(keys);
reload(); reload();
} catch { } catch {
toasts.error("Не удалось вернуть файлы"); problem = "Не удалось вернуть файлы";
} }
} }
@@ -198,7 +201,7 @@
try { try {
await downloadMedia(file.media_id); await downloadMedia(file.media_id);
} catch { } catch {
toasts.error("Не удалось скачать файл"); problem = "Не удалось скачать файл";
} }
} }
@@ -217,6 +220,7 @@
function pick(next: StorageGroupName) { function pick(next: StorageGroupName) {
group = group === next ? null : next; group = group === next ? null : next;
problem = null;
reload(); reload();
} }
@@ -226,14 +230,14 @@
<div class="storage"> <div class="storage">
{#if summary} {#if summary}
{#if summary.files > 0}
<section class="summary"> <section class="summary">
<div class="hero"> <div class="hero">
<span class="total">{formatBytes(summary.bytes) || "0 B"}</span> <span class="total">{formatBytes(summary.bytes)}</span>
<span class="count">{filesLabel(summary.files)} на диске</span> <span class="count">{filesLabel(summary.files)} на диске</span>
</div> </div>
{#if segments.length > 0}
<div class="stack"> <div class="stack">
{#each segments as item (item.group)} {#each groups as item (item.group)}
<span <span
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}" title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}"
style:background-color="var(--color-storage-{item.group})" style:background-color="var(--color-storage-{item.group})"
@@ -241,13 +245,11 @@
></span> ></span>
{/each} {/each}
</div> </div>
{/if}
<div class="legend"> <div class="legend">
{#each summary.groups as item (item.group)} {#each groups as item (item.group)}
<button <button
aria-pressed={group === item.group} aria-pressed={group === item.group}
class="chip" class="chip"
disabled={item.files === 0}
onclick={() => pick(item.group)} onclick={() => pick(item.group)}
type="button" type="button"
class:on={group === item.group} class:on={group === item.group}
@@ -257,11 +259,12 @@
style:background-color="var(--color-storage-{item.group})" style:background-color="var(--color-storage-{item.group})"
></span> ></span>
{GROUP_LABELS[item.group]} {GROUP_LABELS[item.group]}
<span class="value">{formatBytes(item.bytes) || "0"}</span> <span class="value">{formatBytes(item.bytes)}</span>
</button> </button>
{/each} {/each}
</div> </div>
</section> </section>
{/if}
<button <button
class="trash-link" class="trash-link"
@@ -279,6 +282,7 @@
</button> </button>
{/if} {/if}
{#if files.length > 0}
<div class="section-title"> <div class="section-title">
<h3>Самые тяжёлые</h3> <h3>Самые тяжёлые</h3>
{#if summary && group === null && summary.heavy_files > 0} {#if summary && group === null && summary.heavy_files > 0}
@@ -289,6 +293,7 @@
</span> </span>
{/if} {/if}
</div> </div>
{/if}
<div class="list"> <div class="list">
{#each files as file, index (file.media_id)} {#each files as file, index (file.media_id)}
@@ -350,7 +355,8 @@
{#if chosen.length > 0} {#if chosen.length > 0}
<SelectionBar <SelectionBar
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined} note={problem ??
(chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined)}
title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}" title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}"
warn warn
> >
@@ -370,14 +376,27 @@
</SelectionBar> </SelectionBar>
{:else if undo} {:else if undo}
<SelectionBar <SelectionBar
note="{formatBytes(undo.bytes)} освободится через 7 дней" note={problem ?? `${formatBytes(undo.bytes)} освободится через 7 дней`}
title="{filesLabel(undo.files)} в корзине" title="{filesLabel(undo.files)} в корзине"
warn={problem !== null}
> >
<Button onclick={restoreLast} smaller variant="text"> <Button onclick={restoreLast} smaller variant="text">
<Icon name="undo" size="1.25rem" /> <Icon name="undo" size="1.25rem" />
<span>Вернуть</span> <span>Вернуть</span>
</Button> </Button>
</SelectionBar> </SelectionBar>
{:else if problem}
<SelectionBar title={problem}>
<Button
onclick={() => {
problem = null;
}}
smaller
variant="text"
>
Понятно
</Button>
</SelectionBar>
{/if} {/if}
</div> </div>
@@ -482,18 +501,13 @@
box-shadow: inset 0 0 0 1px var(--color-primary); box-shadow: inset 0 0 0 1px var(--color-primary);
} }
&:disabled {
cursor: default;
opacity: 0.5;
}
&:focus-visible { &:focus-visible {
outline: 2px solid var(--color-primary); outline: 2px solid var(--color-primary);
outline-offset: 1px; outline-offset: 1px;
} }
@media (hover: hover) { @media (hover: hover) {
&:not(:disabled, .on):hover { &:not(.on):hover {
background-color: var(--color-background-secondary-accent); background-color: var(--color-background-secondary-accent);
} }
} }
@@ -42,7 +42,7 @@
const MIN_WEIGHT = 0.02; const MIN_WEIGHT = 0.02;
const width = $derived(`${Math.max(MIN_WEIGHT, Math.min(1, weight)) * 100}%`); const scale = $derived(Math.max(MIN_WEIGHT, Math.min(1, weight)));
</script> </script>
<div <div
@@ -81,7 +81,7 @@
{/if} {/if}
</span> </span>
<span class="size">{formatBytes(size)}</span> <span class="size">{formatBytes(size)}</span>
<span class="weight"><span style:width></span></span> <span class="weight"><span style:--scale={scale}></span></span>
</button> </button>
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
@@ -237,6 +237,9 @@
background-color: rgba(var(--color-text-secondary-rgb), 0.16); background-color: rgba(var(--color-text-secondary-rgb), 0.16);
span { span {
transform: scaleX(var(--scale));
transform-origin: 0 50%;
display: block; display: block;
height: 100%; height: 100%;
@@ -244,7 +247,7 @@
background-color: var(--group); background-color: var(--group);
transition: width 0.3s cubic-bezier(0.16, 1, 0.3, 1); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
} }
} }
</style> </style>
@@ -84,7 +84,7 @@
use:poster use:poster
></video> ></video>
{#if shown} {#if shown}
<span class="play"><Icon name="play" size="1.125rem" /></span> <span class="play"><Icon name="play" size="0.875rem" /></span>
{/if} {/if}
{/if} {/if}
</span> </span>
@@ -138,7 +138,15 @@
.play { .play {
position: absolute; position: absolute;
display: flex; display: flex;
text-shadow: 0 0 0.375rem rgb(0 0 0 / 70%); align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background-color: rgb(0 0 0 / 45%);
} }
</style> </style>
@@ -21,7 +21,6 @@
import { plural } from "#lib/format/shares.js"; import { plural } from "#lib/format/shares.js";
import { filesLabel, purgeLabel } from "#lib/format/storage.js"; import { filesLabel, purgeLabel } from "#lib/format/storage.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";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js"; import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -31,6 +30,7 @@
let failed = $state(false); let failed = $state(false);
let busy = $state(false); let busy = $state(false);
let doomed = $state<TrashItem[] | null>(null); let doomed = $state<TrashItem[] | null>(null);
let problem = $state<string | null>(null);
let viewerOpen = $state(false); let viewerOpen = $state(false);
let viewerIndex = $state(0); let viewerIndex = $state(0);
const selected = new SvelteSet<string>(); const selected = new SvelteSet<string>();
@@ -83,6 +83,7 @@
} }
function toggle(key: string) { function toggle(key: string) {
problem = null;
if (!selected.delete(key)) { if (!selected.delete(key)) {
selected.add(key); selected.add(key);
} }
@@ -99,12 +100,13 @@
return; return;
} }
busy = true; busy = true;
problem = null;
const keys = list.map((item) => item.storage_key); const keys = list.map((item) => item.storage_key);
try { try {
await restoreFromTrash(keys); await restoreFromTrash(keys);
drop(keys); drop(keys);
} catch { } catch {
toasts.error("Не удалось вернуть файлы"); problem = "Не удалось вернуть файлы";
} finally { } finally {
busy = false; busy = false;
} }
@@ -115,13 +117,14 @@
return; return;
} }
busy = true; busy = true;
problem = null;
const keys = doomed.map((item) => item.storage_key); const keys = doomed.map((item) => item.storage_key);
try { try {
await purgeTrash(keys); await purgeTrash(keys);
drop(keys); drop(keys);
doomed = null; doomed = null;
} catch { } catch {
toasts.error("Не удалось удалить файлы"); problem = "Не удалось удалить файлы";
} finally { } finally {
busy = false; busy = false;
} }
@@ -215,7 +218,8 @@
{#if chosen.length > 0} {#if chosen.length > 0}
<SelectionBar <SelectionBar
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined} note={problem ??
(chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined)}
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}" title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
warn warn
> >
@@ -240,6 +244,18 @@
<span>Вернуть</span> <span>Вернуть</span>
</Button> </Button>
</SelectionBar> </SelectionBar>
{:else if problem && doomed === null}
<SelectionBar title={problem}>
<Button
onclick={() => {
problem = null;
}}
smaller
variant="text"
>
Понятно
</Button>
</SelectionBar>
{/if} {/if}
</div> </div>
@@ -247,6 +263,7 @@
onOpenChange={(next) => { onOpenChange={(next) => {
if (!next) { if (!next) {
doomed = null; doomed = null;
problem = null;
} }
}} }}
open={doomed !== null} open={doomed !== null}
@@ -280,6 +297,12 @@
можно скачать из Telegram заново. можно скачать из Telegram заново.
{/if} {/if}
</p> </p>
{#if problem}
<p class="confirm warn">
<Icon name="warning" size="1.25rem" />
<span>{problem}.</span>
</p>
{/if}
{#if doomedGone > 0} {#if doomedGone > 0}
<p class="confirm warn"> <p class="confirm warn">
<Icon name="warning" size="1.25rem" /> <Icon name="warning" size="1.25rem" />