2026-06-17 17:06:30 -06:00
|
|
|
<script lang="ts">
|
2026-06-17 22:30:56 -06:00
|
|
|
import { errorToast } from '$lib/utils/errors';
|
2026-06-17 17:06:30 -06:00
|
|
|
import { onMount } from 'svelte';
|
|
|
|
|
import {
|
|
|
|
|
deleteTrashItem,
|
|
|
|
|
emptyTrash,
|
2026-06-17 22:07:18 -06:00
|
|
|
expiryChip,
|
2026-06-17 17:06:30 -06:00
|
|
|
fetchTrashPage,
|
2026-06-17 22:07:18 -06:00
|
|
|
remainingDaysBucket,
|
2026-06-17 17:06:30 -06:00
|
|
|
restoreTrashItem
|
|
|
|
|
} from '$lib/api/endpoints/trash';
|
2026-06-17 22:07:18 -06:00
|
|
|
import { dateBucket, sizeBucket, typeLabel } from '$lib/api/endpoints/favorites';
|
2026-06-23 21:08:55 +02:00
|
|
|
import type { Drive, FileItem, TrashResourceItem } from '$lib/api/types';
|
2026-06-17 22:07:18 -06:00
|
|
|
import Icon from '$lib/icons/Icon.svelte';
|
|
|
|
|
import ResourceList, {
|
|
|
|
|
type GroupByDef,
|
|
|
|
|
type ResourceEntry
|
|
|
|
|
} from '$lib/components/ResourceList.svelte';
|
|
|
|
|
import { confirmDialog } from '$lib/stores/dialogs.svelte';
|
2026-06-17 17:06:30 -06:00
|
|
|
import { t } from '$lib/i18n/index.svelte';
|
2026-06-23 21:08:55 +02:00
|
|
|
import { drives as drivesStore } from '$lib/stores/drives.svelte';
|
2026-06-17 17:06:30 -06:00
|
|
|
import { ui } from '$lib/stores/ui.svelte';
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
let raw = $state<TrashResourceItem[]>([]);
|
2026-06-17 17:06:30 -06:00
|
|
|
let cursor = $state<string | undefined>(undefined);
|
|
|
|
|
let loading = $state(false);
|
|
|
|
|
let error = $state<string | null>(null);
|
2026-06-17 22:07:18 -06:00
|
|
|
// Default: items expiring soonest first, grouped by remaining days.
|
|
|
|
|
let groupBy = $state('remainingDays');
|
|
|
|
|
let reversed = $state(false);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
const entries = $derived(
|
|
|
|
|
raw.map((it): ResourceEntry => {
|
|
|
|
|
const isFile = it.resource_type === 'file';
|
|
|
|
|
return {
|
|
|
|
|
id: it.resource.id,
|
|
|
|
|
name: it.resource.name,
|
|
|
|
|
kind: it.resource_type,
|
|
|
|
|
iconClass: it.resource.icon_class,
|
|
|
|
|
path: it.resource.path,
|
|
|
|
|
size: isFile ? (it.resource as FileItem).size : null,
|
|
|
|
|
// `date` carries the deletion date — rendered as an expiry chip.
|
|
|
|
|
date: it.deletion_date,
|
|
|
|
|
category: isFile ? it.resource.category : 'Folder',
|
2026-06-23 21:08:55 +02:00
|
|
|
modifiedAt: it.trashed_at,
|
|
|
|
|
// D2b: surface drive_id so the Drive group-by can bucket by it.
|
|
|
|
|
// Reuses the existing `ownerId` slot on ResourceEntry — both
|
|
|
|
|
// represent a UUID the listing pivots on; no new field needed.
|
|
|
|
|
ownerId: it.drive_id
|
2026-06-17 22:07:18 -06:00
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-23 21:08:55 +02:00
|
|
|
// "Drive" group rank: default-personal first, then secondary personal, then
|
|
|
|
|
// shared — matches `DrivePicker.svelte::sortedDrives` so the sidebar and
|
|
|
|
|
// trash sections agree on ordering. Used as the bucket sort key.
|
|
|
|
|
function driveRank(d: Drive | null): number {
|
|
|
|
|
if (!d) return 99;
|
|
|
|
|
if (d.default_for_user) return 0;
|
|
|
|
|
return d.kind === 'personal' ? 1 : 2;
|
|
|
|
|
}
|
|
|
|
|
function driveLabel(driveId: string): string {
|
|
|
|
|
const d = drivesStore.findById(driveId);
|
|
|
|
|
return d?.name ?? driveId;
|
|
|
|
|
}
|
|
|
|
|
function driveBucketKey(driveId: string): string {
|
|
|
|
|
// Bucket key has to be a string but we want ordering; prefix with
|
|
|
|
|
// rank so the natural lexical sort puts buckets in the picker's order.
|
|
|
|
|
const d = drivesStore.findById(driveId);
|
|
|
|
|
const rank = driveRank(d).toString().padStart(2, '0');
|
|
|
|
|
return `${rank}:${driveId}`;
|
|
|
|
|
}
|
|
|
|
|
function driveBucketLabel(key: string): string {
|
|
|
|
|
const driveId = key.split(':')[1] ?? key;
|
|
|
|
|
return driveLabel(driveId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
const groupBys: GroupByDef[] = [
|
2026-06-23 21:08:55 +02:00
|
|
|
{ key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' },
|
|
|
|
|
{
|
|
|
|
|
key: 'drive',
|
|
|
|
|
label: t('trash.groupby.drive', 'Drive'),
|
|
|
|
|
orderBy: 'name',
|
|
|
|
|
bucketOf: (e) => (e.ownerId ? driveBucketKey(e.ownerId) : null),
|
|
|
|
|
labelOf: driveBucketLabel
|
|
|
|
|
},
|
2026-06-17 22:07:18 -06:00
|
|
|
{
|
|
|
|
|
key: 'remainingDays',
|
|
|
|
|
label: t('trash.groupby.remaining_days', 'Remaining days'),
|
|
|
|
|
orderBy: 'deletion_date',
|
|
|
|
|
bucketOf: (e) => remainingDaysBucket(e.date)
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'type',
|
|
|
|
|
label: t('groupby.type', 'Type'),
|
|
|
|
|
orderBy: 'type',
|
|
|
|
|
bucketOf: (e) => e.category ?? 'other',
|
|
|
|
|
labelOf: (k) => typeLabel(k)
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'size',
|
|
|
|
|
label: t('groupby.size', 'Size'),
|
|
|
|
|
orderBy: 'size',
|
|
|
|
|
bucketOf: (e) => sizeBucket(e.kind === 'folder' ? null : e.size)
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'trashedTime',
|
|
|
|
|
label: t('trash.groupby.trashed_time', 'Trashed time'),
|
|
|
|
|
orderBy: 'trashed_at',
|
|
|
|
|
bucketOf: (e) => dateBucket(e.modifiedAt)
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function orderByForGroup(): string {
|
|
|
|
|
return groupBys.find((g) => g.key === groupBy)?.orderBy ?? 'deletion_date';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function load(reset = false, orderBy = 'deletion_date', rev = reversed) {
|
2026-06-17 17:06:30 -06:00
|
|
|
loading = true;
|
|
|
|
|
error = null;
|
|
|
|
|
try {
|
2026-06-17 22:07:18 -06:00
|
|
|
const page = await fetchTrashPage({
|
|
|
|
|
cursor: reset ? undefined : cursor,
|
|
|
|
|
orderBy,
|
|
|
|
|
reverse: rev,
|
|
|
|
|
resourceTypes: ['file', 'folder']
|
|
|
|
|
});
|
|
|
|
|
raw = reset ? page.items : [...raw, ...page.items];
|
2026-06-17 17:06:30 -06:00
|
|
|
cursor = page.next_cursor;
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:07:18 -06:00
|
|
|
console.error('trash: load error', e);
|
|
|
|
|
error = t('errors_loadFailed', 'Failed to load items');
|
2026-06-17 17:06:30 -06:00
|
|
|
} finally {
|
|
|
|
|
loading = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
/** Re-fetch from the top so pagination + grouping stay correct after a mutation. */
|
|
|
|
|
async function reloadFromTop() {
|
|
|
|
|
cursor = undefined;
|
|
|
|
|
await load(true, orderByForGroup());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function restore(entry: ResourceEntry) {
|
2026-06-17 17:06:30 -06:00
|
|
|
try {
|
2026-06-17 22:07:18 -06:00
|
|
|
await restoreTrashItem(entry.id);
|
2026-06-17 17:06:30 -06:00
|
|
|
ui.notify(t('trash.restored', 'Restored'), 'success');
|
2026-06-17 22:07:18 -06:00
|
|
|
await reloadFromTop();
|
2026-06-17 17:06:30 -06:00
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
errorToast(e);
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
async function purge(entry: ResourceEntry) {
|
|
|
|
|
const ok = await confirmDialog({
|
|
|
|
|
title: t('trash.delete', 'Delete permanently'),
|
|
|
|
|
message: t('trash.confirm_delete', 'Permanently delete this item? This cannot be undone.'),
|
|
|
|
|
confirmText: t('trash.delete', 'Delete'),
|
|
|
|
|
danger: true
|
|
|
|
|
});
|
|
|
|
|
if (!ok) return;
|
2026-06-17 17:06:30 -06:00
|
|
|
try {
|
2026-06-17 22:07:18 -06:00
|
|
|
await deleteTrashItem(entry.id);
|
|
|
|
|
await reloadFromTop();
|
2026-06-17 17:06:30 -06:00
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
errorToast(e);
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function purgeAll() {
|
2026-06-17 22:07:18 -06:00
|
|
|
const ok = await confirmDialog({
|
|
|
|
|
title: t('trash.empty_action', 'Empty trash'),
|
|
|
|
|
message: t('trash.confirm_empty', 'Empty the trash? This cannot be undone.'),
|
|
|
|
|
confirmText: t('trash.empty_action', 'Empty trash'),
|
|
|
|
|
danger: true
|
|
|
|
|
});
|
|
|
|
|
if (!ok) return;
|
2026-06-17 17:06:30 -06:00
|
|
|
try {
|
|
|
|
|
await emptyTrash();
|
2026-06-17 22:07:18 -06:00
|
|
|
raw = [];
|
2026-06-17 17:06:30 -06:00
|
|
|
cursor = undefined;
|
|
|
|
|
} catch (e) {
|
2026-06-17 22:30:56 -06:00
|
|
|
errorToast(e);
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 21:08:55 +02:00
|
|
|
onMount(() => {
|
|
|
|
|
// Drive names for the "Drive" group-by labels — `drivesStore.load()` is
|
|
|
|
|
// idempotent (cached on the singleton) so this is essentially free.
|
|
|
|
|
void drivesStore.load();
|
|
|
|
|
void load(true);
|
|
|
|
|
});
|
2026-06-17 17:06:30 -06:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<svelte:head><title>{t('nav.trash', 'Trash')} · OxiCloud</title></svelte:head>
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
<ResourceList
|
|
|
|
|
title={t('nav.trash', 'Trash')}
|
|
|
|
|
items={entries}
|
2026-06-17 17:06:30 -06:00
|
|
|
{loading}
|
|
|
|
|
{error}
|
2026-06-17 22:07:18 -06:00
|
|
|
emptyIcon="trash"
|
|
|
|
|
emptyText={t('trash.empty_state', 'Trash is empty')}
|
2026-06-17 17:06:30 -06:00
|
|
|
hasMore={!!cursor}
|
2026-06-17 22:07:18 -06:00
|
|
|
onloadmore={() => load(false, orderByForGroup())}
|
|
|
|
|
pathLabel={t('trash.original_location', 'Original location')}
|
|
|
|
|
dateLabel={t('trash.remaining', 'Remaining')}
|
|
|
|
|
{groupBys}
|
|
|
|
|
bind:groupBy
|
|
|
|
|
bind:reversed
|
|
|
|
|
onreload={(orderBy, rev) => {
|
|
|
|
|
cursor = undefined;
|
|
|
|
|
load(true, orderBy, rev);
|
|
|
|
|
}}
|
2026-06-17 17:06:30 -06:00
|
|
|
>
|
|
|
|
|
{#snippet toolbar()}
|
2026-06-17 22:07:18 -06:00
|
|
|
{#if entries.length > 0}
|
2026-06-21 20:03:32 -06:00
|
|
|
<button class="btn btn-danger" data-testid="trash-empty-btn" onclick={purgeAll}>
|
2026-06-17 22:07:18 -06:00
|
|
|
<Icon name="trash" />
|
2026-06-17 17:06:30 -06:00
|
|
|
{t('trash.empty_action', 'Empty trash')}
|
|
|
|
|
</button>
|
|
|
|
|
{/if}
|
|
|
|
|
{/snippet}
|
2026-06-17 22:07:18 -06:00
|
|
|
{#snippet dateCell(entry)}
|
|
|
|
|
{@const chip = expiryChip(entry.date)}
|
|
|
|
|
<span class="expiry-chip expiry-chip--{chip.tier}">
|
|
|
|
|
<Icon name={chip.icon} class="expiry-chip__icon" />
|
|
|
|
|
{chip.label}
|
|
|
|
|
</span>
|
|
|
|
|
{/snippet}
|
|
|
|
|
{#snippet actions(entry)}
|
2026-06-21 20:03:32 -06:00
|
|
|
<button
|
|
|
|
|
class="btn-action"
|
|
|
|
|
data-testid={`trash-restore-btn-${entry.id}`}
|
|
|
|
|
title={t('trash.restore', 'Restore')}
|
|
|
|
|
onclick={() => restore(entry)}
|
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
<Icon name="undo" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class="btn-action btn-action--delete"
|
2026-06-21 20:03:32 -06:00
|
|
|
data-testid={`trash-delete-btn-${entry.id}`}
|
2026-06-17 22:07:18 -06:00
|
|
|
title={t('trash.delete', 'Delete permanently')}
|
|
|
|
|
onclick={() => purge(entry)}
|
2026-06-17 17:06:30 -06:00
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
<Icon name="trash" />
|
|
|
|
|
</button>
|
|
|
|
|
{/snippet}
|
|
|
|
|
</ResourceList>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
|
|
|
|
<style>
|
2026-06-17 22:07:18 -06:00
|
|
|
/* Tiered expiry chip — ported from static/css expiryChip styles. */
|
|
|
|
|
.expiry-chip {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: var(--space-1);
|
|
|
|
|
padding: var(--space-1) var(--space-2);
|
|
|
|
|
border-radius: var(--radius-pill, var(--radius-md));
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
font-weight: var(--weight-medium, 500);
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
background: var(--color-bg-muted);
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.expiry-chip :global(.expiry-chip__icon) {
|
|
|
|
|
font-size: 0.85em;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.expiry-chip--never {
|
|
|
|
|
background: var(--color-bg-muted);
|
|
|
|
|
color: var(--color-text-faint);
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
.expiry-chip--caution {
|
|
|
|
|
background: var(--color-warning-bg);
|
|
|
|
|
color: var(--color-warning-text-amber);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.expiry-chip--soon {
|
|
|
|
|
background: var(--color-warning-orange-bg);
|
|
|
|
|
color: var(--color-warning-text-orange);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.expiry-chip--urgent {
|
|
|
|
|
background: var(--color-danger-bg);
|
|
|
|
|
color: var(--color-danger-text);
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
.expiry-chip--expired {
|
|
|
|
|
background: var(--color-danger-bg);
|
2026-06-17 17:06:30 -06:00
|
|
|
color: var(--color-danger-text);
|
2026-06-17 22:07:18 -06:00
|
|
|
font-weight: var(--weight-semibold);
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
</style>
|