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