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,
|
||||
|
||||
Reference in New Issue
Block a user