fix(frontend,backend): storage categories keep one hue in both themes, trash counted in the total, undo keeps the list, larger touch targets

This commit is contained in:
hh committed 2026-10-14 03:18:59 +02:00
1 parent 06ae3e928f
commit 580927da0a
11 files changed
+112 -47

No files matched your search

+1
View File
@@ -276,6 +276,7 @@ class TrashItemView(BaseModel):
file_size: int | None file_size: int | None
trashed_at: datetime trashed_at: datetime
purge_at: datetime purge_at: datetime
refs: int
refetchable: bool refetchable: bool
+4
View File
@@ -76,6 +76,7 @@ ORDER BY file_size DESC NULLS LAST, storage_key
_TRASH_REFS = """ _TRASH_REFS = """
SELECT * FROM ( SELECT * FROM (
SELECT evicted_key AS storage_key, chat_id, message_id, kind, ttl_seconds, SELECT evicted_key AS storage_key, chat_id, message_id, kind, ttl_seconds,
count(*) OVER (PARTITION BY evicted_key) AS refs,
row_number() OVER (PARTITION BY evicted_key ORDER BY id) AS n row_number() OVER (PARTITION BY evicted_key ORDER BY id) AS n
FROM media FROM media
WHERE account_id = $1 AND evicted_key = ANY($2::text[]) WHERE account_id = $1 AND evicted_key = ANY($2::text[])
@@ -185,12 +186,15 @@ async def list_trash(pool: asyncpg.Pool, account_id: int) -> list[TrashItemView]
) )
messages = await _messages(pool, account_id, refs) messages = await _messages(pool, account_id, refs)
alive: dict[str, bool] = defaultdict(bool) alive: dict[str, bool] = defaultdict(bool)
counts: dict[str, int] = {}
for ref, message in zip(refs, messages, strict=True): for ref, message in zip(refs, messages, strict=True):
alive[ref["storage_key"]] |= _alive(ref, message) alive[ref["storage_key"]] |= _alive(ref, message)
counts[ref["storage_key"]] = ref["refs"]
return [ return [
TrashItemView( TrashItemView(
**dict(row), **dict(row),
purge_at=row["trashed_at"] + RETENTION, purge_at=row["trashed_at"] + RETENTION,
refs=counts.get(row["storage_key"], 0),
refetchable=alive.get(row["storage_key"], True), refetchable=alive.get(row["storage_key"], True),
) )
for row in rows for row in rows
+1
View File
@@ -19,6 +19,7 @@ export type InlineMedia =
| { state: "missing" }; | { state: "missing" };
export interface ViewerItem { export interface ViewerItem {
caption?: string;
downloaded: boolean; downloaded: boolean;
fileName: string | null; fileName: string | null;
fileSize: number | null; fileSize: number | null;
+1
View File
@@ -602,6 +602,7 @@ export interface TrashItem {
mime: string | null; mime: string | null;
purge_at: string; purge_at: string;
refetchable: boolean; refetchable: boolean;
refs: number;
storage_key: string; storage_key: string;
trashed_at: string; trashed_at: string;
} }
+28 -10
View File
@@ -62,6 +62,7 @@
currentMediaId !== null && (fileOnly || result?.state === "ready") currentMediaId !== null && (fileOnly || result?.state === "ready")
); );
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа"); const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
const caption = $derived(items[index]?.caption);
function revoke() { function revoke() {
if (result?.state === "ready" && result.url && !streamed) { if (result?.state === "ready" && result.url && !streamed) {
@@ -217,8 +218,21 @@
<Dialog.Overlay class="media-overlay" /> <Dialog.Overlay class="media-overlay" />
<Dialog.Content class="media-content"> <Dialog.Content class="media-content">
<Dialog.Title class="media-title">{title}</Dialog.Title> <Dialog.Title class="media-title">{title}</Dialog.Title>
{#if caption}
<span class="media-caption">{caption}</span>
{/if}
<div class="media-actions"> <div class="media-actions">
{#if canSave} {#if canSave}
{#if ontrash}
<button
aria-label="В корзину"
class="media-action"
onclick={trash}
type="button"
>
<Icon name="delete" size="1.375rem" />
</button>
{/if}
<ContextMenu> <ContextMenu>
{#snippet children({ {#snippet children({
props, props,
@@ -250,16 +264,6 @@
> >
<Icon name="allow-share" size="1.375rem" /> <Icon name="allow-share" size="1.375rem" />
</button> </button>
{#if ontrash}
<button
aria-label="В корзину"
class="media-action"
onclick={trash}
type="button"
>
<Icon name="delete" size="1.375rem" />
</button>
{/if}
{/if} {/if}
<Dialog.Close aria-label="Закрыть" class="media-close"> <Dialog.Close aria-label="Закрыть" class="media-close">
<Icon name="close" size="1.5rem" /> <Icon name="close" size="1.5rem" />
@@ -371,6 +375,20 @@
white-space: nowrap; white-space: nowrap;
} }
.media-caption {
position: absolute;
top: 2.75rem;
left: 1.25rem;
overflow: hidden;
max-width: min(24rem, calc(100% - 14rem));
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.6);
text-overflow: ellipsis;
white-space: nowrap;
}
.media-counter { .media-counter {
position: absolute; position: absolute;
top: 1rem; top: 1rem;
@@ -45,7 +45,9 @@
let failed = $state(false); let failed = $state(false);
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<{ files: StorageFile[]; outcome: TrashOutcome } | null>(
null
);
let problem = $state<string | null>(null); let problem = $state<string | null>(null);
let viewerOpen = $state(false); let viewerOpen = $state(false);
let viewerIndex = $state(0); let viewerIndex = $state(0);
@@ -67,6 +69,7 @@
); );
const viewerItems = $derived<ViewerItem[]>( const viewerItems = $derived<ViewerItem[]>(
files.map((file) => ({ files.map((file) => ({
caption: `${formatBytes(file.file_size)} · ${chatTitle(file.chat_id)}`,
downloaded: true, downloaded: true,
fileName: file.file_name, fileName: file.file_name,
fileSize: file.file_size, fileSize: file.file_size,
@@ -133,7 +136,6 @@
} }
function toggle(mediaId: number) { function toggle(mediaId: number) {
hideUndo();
problem = null; problem = null;
if (!selected.delete(mediaId)) { if (!selected.delete(mediaId)) {
selected.add(mediaId); selected.add(mediaId);
@@ -145,10 +147,10 @@
undo = null; undo = null;
} }
function showUndo(outcome: TrashOutcome) { function showUndo(outcome: TrashOutcome, trashed: StorageFile[]) {
hideUndo(); hideUndo();
if (outcome.files > 0) { if (outcome.files > 0) {
undo = outcome; undo = { files: trashed, outcome };
undoTimer = setTimeout(hideUndo, UNDO_MS); undoTimer = setTimeout(hideUndo, UNDO_MS);
} }
} }
@@ -164,14 +166,13 @@
const moved = new Set( const moved = new Set(
mediaIds.filter((id) => !outcome.blocked.includes(id)) mediaIds.filter((id) => !outcome.blocked.includes(id))
); );
for (const file of files) { const trashed = files.filter((file) => moved.has(file.media_id));
if (moved.has(file.media_id)) { for (const file of trashed) {
forgetMedia(file.chat_id, file.message_id, file.media_id); forgetMedia(file.chat_id, file.message_id, file.media_id);
}
} }
files = files.filter((file) => !moved.has(file.media_id)); files = files.filter((file) => !moved.has(file.media_id));
selected.clear(); selected.clear();
showUndo(outcome); showUndo(outcome, trashed);
if (outcome.blocked.length > 0) { if (outcome.blocked.length > 0) {
problem = "Файл занят в сторис или аватаре и остался на диске"; problem = "Файл занят в сторис или аватаре и остался на диске";
} }
@@ -187,11 +188,14 @@
if (undo === null) { if (undo === null) {
return; return;
} }
const { keys } = undo; const restored = undo;
hideUndo(); hideUndo();
try { try {
await restoreFromTrash(keys); await restoreFromTrash(restored.outcome.keys);
reload(); files = [...files, ...restored.files].sort(
(a, b) => b.file_size - a.file_size
);
await loadSummary();
} catch { } catch {
problem = "Не удалось вернуть файлы"; problem = "Не удалось вернуть файлы";
} }
@@ -234,7 +238,12 @@
<section class="summary"> <section class="summary">
<div class="hero"> <div class="hero">
<span class="total">{formatBytes(summary.bytes)}</span> <span class="total">{formatBytes(summary.bytes)}</span>
<span class="count">{filesLabel(summary.files)} на диске</span> <span class="count">
{filesLabel(summary.files)}
{summary.trash_bytes > 0
? `· ещё ${formatBytes(summary.trash_bytes)} в корзине`
: "на диске"}
</span>
</div> </div>
<div class="stack"> <div class="stack">
{#each groups as item (item.group)} {#each groups as item (item.group)}
@@ -244,6 +253,13 @@
style:flex-grow={item.bytes} style:flex-grow={item.bytes}
></span> ></span>
{/each} {/each}
{#if summary.trash_bytes > 0}
<span
class="trashed"
title="Корзина: {formatBytes(summary.trash_bytes)}"
style:flex-grow={summary.trash_bytes}
></span>
{/if}
</div> </div>
<div class="legend"> <div class="legend">
{#each groups as item (item.group)} {#each groups as item (item.group)}
@@ -376,8 +392,9 @@
</SelectionBar> </SelectionBar>
{:else if undo} {:else if undo}
<SelectionBar <SelectionBar
note={problem ?? `${formatBytes(undo.bytes)} освободится через 7 дней`} note={problem ??
title="{filesLabel(undo.files)} в корзине" `${formatBytes(undo.outcome.bytes)} освободится через 7 дней`}
title="{filesLabel(undo.outcome.files)} в корзине"
warn={problem !== null} warn={problem !== null}
> >
<Button onclick={restoreLast} smaller variant="text"> <Button onclick={restoreLast} smaller variant="text">
@@ -454,6 +471,10 @@
min-width: 0.1875rem; min-width: 0.1875rem;
border-radius: 2px; border-radius: 2px;
&.trashed {
background-color: rgba(var(--color-text-secondary-rgb), 0.35);
}
&:first-child { &:first-child {
border-start-start-radius: 0.3125rem; border-start-start-radius: 0.3125rem;
border-end-start-radius: 0.3125rem; border-end-start-radius: 0.3125rem;
@@ -501,6 +522,12 @@
box-shadow: inset 0 0 0 1px var(--color-primary); box-shadow: inset 0 0 0 1px var(--color-primary);
} }
@media (pointer: coarse) {
height: 2.25rem;
padding: 0 0.75rem 0 0.625rem;
border-radius: 1.125rem;
}
&:focus-visible { &:focus-visible {
outline: 2px solid var(--color-primary); outline: 2px solid var(--color-primary);
outline-offset: 1px; outline-offset: 1px;
@@ -113,8 +113,9 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
align-self: stretch;
width: 2.5rem; width: 2.5rem;
height: 2.5rem; min-height: 2.5rem;
input { input {
cursor: pointer; cursor: pointer;
@@ -19,7 +19,11 @@
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes } from "#lib/format/media.js"; import { formatBytes } from "#lib/format/media.js";
import { plural } from "#lib/format/shares.js"; import { plural } from "#lib/format/shares.js";
import { filesLabel, purgeLabel } from "#lib/format/storage.js"; import {
filesLabel,
messagesLabel,
purgeLabel,
} from "#lib/format/storage.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.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";
@@ -50,6 +54,7 @@
); );
const viewerItems = $derived<ViewerItem[]>( const viewerItems = $derived<ViewerItem[]>(
items.map((item) => ({ items.map((item) => ({
caption: `${formatBytes(item.file_size)} · ${chatTitle(item.chat_id)}`,
downloaded: true, downloaded: true,
fileName: item.file_name, fileName: item.file_name,
fileSize: item.file_size, fileSize: item.file_size,
@@ -180,6 +185,7 @@
size={item.file_size} size={item.file_size}
src={trashedUrl(item.storage_key)} src={trashedUrl(item.storage_key)}
sub={chatTitle(item.chat_id)} sub={chatTitle(item.chat_id)}
tail={item.refs > 1 ? messagesLabel(item.refs) : undefined}
weight={heaviest > 0 ? (item.file_size ?? 0) / heaviest : 0} weight={heaviest > 0 ? (item.file_size ?? 0) / heaviest : 0}
> >
{#snippet menu()} {#snippet menu()}
@@ -292,17 +298,7 @@
"будут удалены" "будут удалены"
)} )}
с диска. с диска.
{#if doomedGone < doomed.length}
{doomedGone === 0 ? "Потом их" : "Остальные потом"}
можно скачать из Telegram заново.
{/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" />
@@ -314,6 +310,18 @@
</span> </span>
</p> </p>
{/if} {/if}
{#if doomedGone < doomed.length}
<p class="confirm rest">
{doomedGone === 0 ? "Потом их" : "Остальные"}
можно скачать из Telegram заново.
</p>
{/if}
{#if problem}
<p class="confirm warn">
<Icon name="warning" size="1.25rem" />
<span>{problem}.</span>
</p>
{/if}
</div> </div>
{/if} {/if}
<div class="dialog-actions"> <div class="dialog-actions">
@@ -431,6 +439,10 @@
font-size: 0.9375rem; font-size: 0.9375rem;
line-height: 1.45; line-height: 1.45;
&.rest {
margin-top: 0.75rem;
}
&.warn { &.warn {
display: flex; display: flex;
gap: 0.625rem; gap: 0.625rem;
+3 -3
View File
@@ -42,10 +42,10 @@ export function fileExtension(fileName: string): string {
export function purgeLabel(iso: string): string { export function purgeLabel(iso: string): string {
const days = Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS); const days = Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS);
if (days <= 0) { if (days <= 0) {
return "Удалится сегодня"; return "сегодня";
} }
if (days === 1) { if (days === 1) {
return "Удалится завтра"; return "завтра";
} }
return `Удалится через ${days} дн.`; return `через ${days} дн.`;
} }
+1 -1
View File
@@ -5,7 +5,7 @@ html.theme-dark {
--color-storage-audio: #c98500; --color-storage-audio: #c98500;
--color-storage-other: #3987e5; --color-storage-other: #3987e5;
--color-storage-warning: #fb8c00; --color-storage-warning: #fb8c00;
--storage-tile-mix: 78%; --storage-tile-mix: 90%;
--color-primary: #8774e1; --color-primary: #8774e1;
--color-primary-opacity: #8378db1e; --color-primary-opacity: #8378db1e;
--color-primary-opacity-hover: #8378db40; --color-primary-opacity-hover: #8378db40;
+6 -6
View File
@@ -133,13 +133,13 @@
--color-warning: #{$color-warning}; --color-warning: #{$color-warning};
--color-storage-video: #3390ec; --color-storage-video: #6250d6;
--color-storage-file: #eb6834; --color-storage-file: #c4501c;
--color-storage-voice: #1baf7a; --color-storage-voice: #12805a;
--color-storage-audio: #eda100; --color-storage-audio: #a36a00;
--color-storage-other: #6250d6; --color-storage-other: #2a78c8;
--color-storage-warning: #a85a00; --color-storage-warning: #a85a00;
--storage-tile-mix: 62%; --storage-tile-mix: 100%;
--color-yellow: #{$color-yellow}; --color-yellow: #{$color-yellow};