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:
Edouard Vanbelle
2026-05-30 00:15:07 +02:00
parent 6dab878919
commit ea83891a61
34 changed files with 2043 additions and 418 deletions
+161 -1
View File
@@ -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,
+4 -1
View File
@@ -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
View File
@@ -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.
*/
/**