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:
1 parent
06ae3e928f
commit
580927da0a
11 files changed
+112
-47
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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} дн.`;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user