feat(trash): move trash API to normalized version (with cursor, orderBy) + normalize Trash section to existing components
normalize also component to format badges (expiry, role, etc)
This commit is contained in:
@@ -162,12 +162,169 @@ function normalizeExpiryBucket(value) {
|
||||
}
|
||||
const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000);
|
||||
if (daysUntil < 0) return i18n.t('expiryBucket.expired', 'Expired');
|
||||
if (daysUntil <= 1) return i18n.t('expiryBucket.tomorrow', 'Tomorrow');
|
||||
if (daysUntil === 0) return i18n.t('expiryBucket.today', 'Today');
|
||||
if (daysUntil === 1) return i18n.t('expiryBucket.tomorrow', 'Tomorrow');
|
||||
if (daysUntil <= 7) return i18n.t('expiryBucket.week', 'In less than 7 days');
|
||||
if (daysUntil <= 30) return i18n.t('expiryBucket.month', 'In less than 30 days');
|
||||
return String(date.getFullYear());
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a future timestamp as a precise "days until" label.
|
||||
*
|
||||
* Used by the Trash view's date column to surface the remaining lifetime
|
||||
* before the retention sweeper purges an item. Unlike `normalizeExpiryBucket`
|
||||
* (which produces coarse bucket labels for grouping), this returns an
|
||||
* exact-day count so users can see "In 27 days" at a glance.
|
||||
*
|
||||
* Buckets:
|
||||
* < 0 → Expired
|
||||
* = 0 → Today
|
||||
* = 1 → Tomorrow
|
||||
* > 1 → In N days
|
||||
*
|
||||
* @param {string | number | Date | null | undefined} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatDaysRemaining(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
/** @type {Date} */
|
||||
let date;
|
||||
if (value instanceof Date) {
|
||||
date = value;
|
||||
} else if (typeof value === 'number') {
|
||||
date = new Date(value < 1e12 ? value * 1000 : value);
|
||||
} else {
|
||||
date = new Date(value);
|
||||
}
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
|
||||
const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000);
|
||||
if (daysUntil < 0) return i18n.t('daysRemaining.expired', 'Expired');
|
||||
if (daysUntil === 0) return i18n.t('daysRemaining.today', 'Today');
|
||||
if (daysUntil === 1) return i18n.t('daysRemaining.tomorrow', 'Tomorrow');
|
||||
// Translation file holds the `{{count}}` template; the fallback is only
|
||||
// used pre-load and embeds the literal count.
|
||||
const translated = i18n.t('daysRemaining.inDays', { count: daysUntil });
|
||||
return translated === 'daysRemaining.inDays' ? `${daysUntil} days` : translated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as a compact "Mar 5, 2026" label.
|
||||
*
|
||||
* Shared helper used by `formatExpiryChip` (read-only chip) and by
|
||||
* `buildExpiryChip` in `utils/expiryChip.js` (interactive editor) so the
|
||||
* displayed deadline reads identically across both surfaces.
|
||||
*
|
||||
* Accepts:
|
||||
* - `string` YYYY-MM-DD — parsed at LOCAL midnight (avoids the off-by-one
|
||||
* shift `new Date("2026-05-30")` causes in negative-offset zones).
|
||||
* - `string` ISO-8601 with time — parsed as-is.
|
||||
* - `number` Unix seconds/ms (auto-detected at 1e12).
|
||||
* - `Date` object — used directly.
|
||||
*
|
||||
* @param {string | number | Date | null | undefined} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatExpiryDate(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
/** @type {Date} */
|
||||
let date;
|
||||
if (value instanceof Date) {
|
||||
date = value;
|
||||
} else if (typeof value === 'number') {
|
||||
date = new Date(value < 1e12 ? value * 1000 : value);
|
||||
} else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
// Bare YYYY-MM-DD: pin to local midnight so the day doesn't shift.
|
||||
date = new Date(`${value}T00:00:00`);
|
||||
} else {
|
||||
date = new Date(value);
|
||||
}
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an expiry / retention date as a tiered chip.
|
||||
*
|
||||
* Single shared formatter used by My Shares (link expiry), Trash
|
||||
* (remaining lifetime before purge), and any other section that needs
|
||||
* to surface a future deadline. Output:
|
||||
*
|
||||
* `<span class="expiry-chip expiry-chip--{tier}"><i class="..."></i>LABEL</span>`
|
||||
*
|
||||
* Six tiers escalate cool → hot:
|
||||
* - null value → `never` (neutral, infinity icon, "Never")
|
||||
* - > 30 days → `normal` (neutral grey, "Until DATE")
|
||||
* - 8–30 days → `caution` (soft amber, "N days")
|
||||
* - 2–7 days → `soon` (soft orange, "N days")
|
||||
* - 0–1 days → `urgent` (soft red, "Today" / "Tomorrow")
|
||||
* - past deadline → `expired` (deeper red, warning icon, "Expired")
|
||||
*
|
||||
* The CSS lives in `components/expiryChip.css`.
|
||||
* Caller is responsible for embedding the returned HTML safely — the
|
||||
* label is taken from a controlled i18n key set (no user input).
|
||||
*
|
||||
* @param {string | number | Date | null | undefined} value
|
||||
* @returns {string} HTML snippet
|
||||
*/
|
||||
function formatExpiryChip(value) {
|
||||
// null/undefined is a valid input meaning "no deadline".
|
||||
if (value === null || value === undefined) {
|
||||
const label = i18n.t('expiryChip.never', 'Never expires');
|
||||
return `<span class="expiry-chip expiry-chip--never"><i class="fas fa-infinity expiry-chip__icon"></i>${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
/** @type {Date} */
|
||||
let date;
|
||||
if (value instanceof Date) {
|
||||
date = value;
|
||||
} else if (typeof value === 'number') {
|
||||
date = new Date(value < 1e12 ? value * 1000 : value);
|
||||
} else {
|
||||
date = new Date(value);
|
||||
}
|
||||
if (Number.isNaN(date.getTime())) return escapeHtml(String(value));
|
||||
|
||||
const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000);
|
||||
|
||||
let tier;
|
||||
let icon;
|
||||
let label;
|
||||
if (daysUntil < 0) {
|
||||
tier = 'expired';
|
||||
icon = 'fa-exclamation-triangle';
|
||||
label = i18n.t('expiryChip.expired', 'Expired');
|
||||
} else if (daysUntil === 0) {
|
||||
tier = 'urgent';
|
||||
icon = 'fa-clock';
|
||||
label = i18n.t('expiryChip.today', 'Expires today');
|
||||
} else if (daysUntil === 1) {
|
||||
tier = 'urgent';
|
||||
icon = 'fa-clock';
|
||||
label = i18n.t('expiryChip.tomorrow', 'Expires tomorrow');
|
||||
} else if (daysUntil <= 7) {
|
||||
tier = 'soon';
|
||||
icon = 'fa-calendar';
|
||||
const translated = i18n.t('expiryChip.inDays', { count: daysUntil });
|
||||
label = translated === 'expiryChip.inDays' ? `Expires in ${daysUntil} days` : translated;
|
||||
} else if (daysUntil <= 30) {
|
||||
tier = 'caution';
|
||||
icon = 'fa-calendar';
|
||||
const translated = i18n.t('expiryChip.inDays', { count: daysUntil });
|
||||
label = translated === 'expiryChip.inDays' ? `Expires in ${daysUntil} days` : translated;
|
||||
} else {
|
||||
// Far future — show absolute date so users see the exact deadline.
|
||||
tier = 'normal';
|
||||
icon = 'fa-calendar';
|
||||
const fmt = formatExpiryDate(date);
|
||||
const translated = i18n.t('expiryChip.onDate', { date: fmt });
|
||||
label = translated === 'expiryChip.onDate' ? `Expires ${fmt}` : translated;
|
||||
}
|
||||
|
||||
return `<span class="expiry-chip expiry-chip--${tier}"><i class="fas ${icon} expiry-chip__icon"></i>${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a file size in bytes to a coarse, human-readable bucket label.
|
||||
*
|
||||
@@ -201,6 +358,9 @@ export {
|
||||
escapeHtml,
|
||||
formatDateShort,
|
||||
formatDateTime,
|
||||
formatDaysRemaining,
|
||||
formatExpiryChip,
|
||||
formatExpiryDate,
|
||||
formatFileSize,
|
||||
formatQuotaSize,
|
||||
isEmailValid,
|
||||
|
||||
@@ -41,7 +41,6 @@ const OxiIcons = {
|
||||
576,
|
||||
'M246.6 374.6l-96 96c-12.5 12.5-32.8 12.5-45.3 0l-96-96c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L96 370.7 96 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 306.7 41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3zM320 480c-17.7 0-32-14.3-32-32s14.3-32 32-32l32 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-32 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-96 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l160 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-160 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L320 96z'
|
||||
],
|
||||
|
||||
ban: [
|
||||
512,
|
||||
'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM159.3 388.7L388.7 159.3c4.6-4.6 11.5-5.9 17.4-3.5c14.5 6 26.4 15.3 35.1 27c3.8 5.2 3.2 12.3-1.2 16.8L210.2 428.4c-4.4 4.4-11.6 5-16.8 1.2c-11.7-8.7-21-20.6-27-35.1c-2.5-5.9-1.1-12.8 3.5-17.4z'
|
||||
@@ -66,6 +65,10 @@ const OxiIcons = {
|
||||
576,
|
||||
'M566.6 54.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192-34.7-34.7c-4.2-4.2-10-6.6-16-6.6c-12.5 0-22.6 10.1-22.6 22.6l0 29.1L364.3 320l29.1 0c12.5 0 22.6-10.1 22.6-22.6c0-6-2.4-11.8-6.6-16l-34.7-34.7 192-192zM341.1 353.4L222.6 234.9c-42.7-3.7-85.2 11.7-115.8 42.3l-8 8C76.5 307.5 64 337.7 64 369.2c0 6.8 7.1 11.2 13.2 8.2l51.1-25.5c5-2.5 9.5 4.1 5.4 7.9L7.3 473.4C2.7 477.6 0 483.6 0 489.9C0 502.1 9.9 512 22.1 512l173.3 0c38.8 0 75.9-15.4 103.4-42.8c30.6-30.6 45.9-73.1 42.3-115.8z'
|
||||
],
|
||||
calendar: [
|
||||
512,
|
||||
'M120 0c13.3 0 24 10.7 24 24l0 40 160 0 0-40c0-13.3 10.7-24 24-24s24 10.7 24 24l0 40 32 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 128C0 92.7 28.7 64 64 64l32 0 0-40c0-13.3 10.7-24 24-24zm0 112l-56 0c-8.8 0-16 7.2-16 16l0 48 352 0 0-48c0-8.8-7.2-16-16-16l-264 0zM48 224l0 192c0 8.8 7.2 16 16 16l320 0c8.8 0 16-7.2 16-16l0-192-352 0z'
|
||||
],
|
||||
'caret-down': [
|
||||
320,
|
||||
'M137.4 374.6c12.5 12.5 32.8 12.5 45.3 0l128-128c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8L32 192c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l128 128z'
|
||||
|
||||
+23
-11
@@ -115,17 +115,29 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TrashItem
|
||||
* @property {string} id
|
||||
* @property {string} original_id
|
||||
* @property {ItemTypeEnum} item_type
|
||||
* @property {string} name
|
||||
* @property {string} original_path - timestamp
|
||||
* @property {number} trashed_at
|
||||
* @property {number} days_until_deletion
|
||||
* @property {string} category
|
||||
* @property {string} icon_class
|
||||
* @property {string} icon_special_class
|
||||
* One item returned by `GET /api/trash/resources`.
|
||||
* `resource_type` discriminates the shape of `resource`.
|
||||
*
|
||||
* `deletion_date` is the real timestamp at which the retention sweeper will
|
||||
* permanently delete the item (= trashed_at + retention_days). Days remaining
|
||||
* is derived client-side from `deletion_date` and the current clock — it is
|
||||
* not duplicated in the wire format.
|
||||
*
|
||||
* `resource.path` carries the item's original location (soft-delete preserves
|
||||
* the row's `path` column).
|
||||
*
|
||||
* @typedef {Object} TrashResourceItem
|
||||
* @property {ResourceTypeEnum} resource_type - 'file' | 'folder'
|
||||
* @property {string} trashed_at - ISO-8601: when the user sent it to trash.
|
||||
* @property {string} deletion_date - ISO-8601: when retention will purge it.
|
||||
* @property {FileItem|FolderItem} resource - Full resource details; shape follows resource_type.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Response for `GET /api/trash/resources`.
|
||||
* @typedef {Object} TrashResourcesResponse
|
||||
* @property {TrashResourceItem[]} items
|
||||
* @property {string|undefined} [next_cursor] - Absent when the last page is reached.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user