Merge branch 'main' into idp-auto-redirect

This commit is contained in:
Markus Schmidt
2026-07-18 20:16:54 +02:00
committed by GitHub
180 changed files with 23338 additions and 2554 deletions
@@ -62,6 +62,7 @@
typeLabel
} from '$lib/stores/files.svelte';
import { formatBytes } from '$lib/utils/format';
import { replaceSet } from '$lib/utils/sets';
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
import { gridColumns } from '$lib/utils/grid';
import {
@@ -166,8 +167,11 @@
// Favorite + shared badge sets for the current folder, seeded directly from
// the listing response (server-computed, scoped to these items — no extra
// per-navigation fetch) and updated optimistically on mutation.
let favoriteIds = $state<Set<string>>(new Set());
let sharedIds = $state<Set<string>>(new Set());
// `SvelteSet` mutated in place: a toggle costs O(1) instead of copying
// the whole set, and every other present-key `.has()` reader is spared
// (measured in selectionPatterns.bench.test.ts).
const favoriteIds = new SvelteSet<string>();
const sharedIds = new SvelteSet<string>();
function openMove(kind: ItemType, id: string, name: string) {
actionTarget = { id, name, kind };
@@ -189,19 +193,15 @@
async function toggleFavorite(kind: ItemType, id: string) {
const isFav = favoriteIds.has(id);
// Optimistic toggle, reverted on failure.
const next = new SvelteSet(favoriteIds);
if (isFav) next.delete(id);
else next.add(id);
favoriteIds = next;
if (isFav) favoriteIds.delete(id);
else favoriteIds.add(id);
try {
if (isFav) await removeFavorite(kind, id);
else await addFavorite(kind, id);
} catch (e) {
errorToast(e);
const reverted = new SvelteSet(favoriteIds);
if (isFav) reverted.add(id);
else reverted.delete(id);
favoriteIds = reverted;
if (isFav) favoriteIds.add(id);
else favoriteIds.delete(id);
}
}
@@ -229,8 +229,8 @@
function applyListing(data: FolderListing) {
listing = data;
favoriteIds = new Set(data.favoriteIds);
sharedIds = new Set(data.sharedIds);
replaceSet(favoriteIds, data.favoriteIds);
replaceSet(sharedIds, data.sharedIds);
}
async function load() {
@@ -881,19 +881,20 @@
}
// ── Multi-select + batch ────────────────────────────────────────────────
let selected = $state<Set<string>>(new Set());
// In-place `SvelteSet`: a toggle is O(1) (no full-set copy) and spares
// the other selected rows' `has()` readers — decisive when refining a
// select-all (selectionPatterns.bench.test.ts).
const selected = new SvelteSet<string>();
// Anchor row id for shift-click range selection.
let selectionAnchor = $state<string | null>(null);
function toggleSelected(id: string) {
const next = new SvelteSet(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
selected = next;
if (selected.has(id)) selected.delete(id);
else selected.add(id);
selectionAnchor = id;
}
function clearSelection() {
selected = new Set();
selected.clear();
selectionAnchor = null;
}
@@ -911,7 +912,7 @@
const b = orderedIds.indexOf(id);
if (a !== -1 && b !== -1) {
const [lo, hi] = a < b ? [a, b] : [b, a];
selected = new Set([...selected, ...orderedIds.slice(lo, hi + 1)]);
for (let i = lo; i <= hi; i++) selected.add(orderedIds[i]);
}
return true;
}
@@ -927,11 +928,16 @@
const totalCount = $derived(visibleFolders.length + visibleFiles.length);
function toggleSelectAll() {
if (selected.size === totalCount) clearSelection();
// Select-all only picks what the user can see — dotfiles hidden
// by the current filter are excluded so "select all → delete"
// can't accidentally sweep up hidden files the user never saw.
else selected = new Set([...visibleFolders, ...visibleFiles].map((i) => i.id));
if (selected.size === totalCount) {
clearSelection();
} else {
// Select-all only picks what the user can see — dotfiles hidden
// by the current filter are excluded so "select all → delete"
// can't accidentally sweep up hidden files the user never saw.
selected.clear();
for (const i of visibleFolders) selected.add(i.id);
for (const i of visibleFiles) selected.add(i.id);
}
}
/**
@@ -948,9 +954,12 @@
async function batchDownload() {
const fileIds: string[] = [];
const folderIds: string[] = [];
// One O(M) pass over the listing instead of an O(N·M) `some` per id.
const folderIdSet = new Set(listing.folders.map((f) => f.id));
const fileIdSet = new Set(listing.files.map((f) => f.id));
for (const id of selected) {
if (listing.folders.some((f) => f.id === id)) folderIds.push(id);
else if (listing.files.some((f) => f.id === id)) fileIds.push(id);
if (folderIdSet.has(id)) folderIds.push(id);
else if (fileIdSet.has(id)) fileIds.push(id);
}
if (fileIds.length === 0 && folderIds.length === 0) return;
@@ -1009,7 +1018,7 @@
})
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
favoriteIds = new Set([...favoriteIds, ...items.map((it) => it.id)]);
for (const it of items) favoriteIds.add(it.id);
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
clearSelection();
} catch (e) {
@@ -1018,13 +1027,14 @@
}
function selectionTargets(): ActionTarget[] {
// One O(M) index build instead of an O(N·M) `find` per selected id.
// Folders win id collisions, matching the old folder-first probe.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read
const byId = new Map<string, ActionTarget>();
for (const f of listing.files) byId.set(f.id, { id: f.id, name: f.name, kind: 'file' });
for (const f of listing.folders) byId.set(f.id, { id: f.id, name: f.name, kind: 'folder' });
return [...selected]
.map((id) => {
const folder = listing.folders.find((f) => f.id === id);
if (folder) return { id, name: folder.name, kind: 'folder' as ItemType };
const file = listing.files.find((f) => f.id === id);
return file ? { id, name: file.name, kind: 'file' as ItemType } : null;
})
.map((id) => byId.get(id) ?? null)
.filter((x): x is ActionTarget => x !== null);
}
@@ -1070,15 +1080,19 @@
danger: true
});
if (!ok) return;
for (const id of ids) {
const folder = listing.folders.find((f) => f.id === id);
// Bounded fan-out instead of a serial await per item: 100 deletes at
// ~30 ms RTT collapse from ~3 s of waterfall to a few round-trip
// windows. Failures toast individually and the rest still proceed,
// exactly like the old serial loop.
const folderIdSet = new Set(listing.folders.map((f) => f.id));
await mapLimit(ids, 6, async (id) => {
try {
if (folder) await deleteFolder(id);
if (folderIdSet.has(id)) await deleteFolder(id);
else await deleteFile(id);
} catch (e) {
errorToast(e);
}
}
});
clearSelection();
await reload();
void session.refresh();
@@ -1185,16 +1199,26 @@
async function moveInto(targetFolderId: string, e: DragEvent) {
const items = dragPayload(e).filter((it) => it.id !== targetFolderId);
if (items.length === 0) return;
try {
for (const it of items) {
if (it.kind === 'file') await moveFile(it.id, targetFolderId);
else await moveFolder(it.id, targetFolderId);
}
clearSelection();
await reload();
} catch (err) {
errorToast(err);
// Bounded fan-out (was a serial await per item). Every item is
// attempted; on any failure the first error is surfaced and the
// selection is kept so the drop can be retried, like the old loop.
const failures = (
await mapLimit(items, 6, async (it) => {
try {
if (it.kind === 'file') await moveFile(it.id, targetFolderId);
else await moveFolder(it.id, targetFolderId);
return null;
} catch (err) {
return err ?? new Error('move failed');
}
})
).filter((err) => err !== null);
if (failures.length > 0) {
errorToast(failures[0]);
return;
}
clearSelection();
await reload();
}
function onFolderDrop(e: DragEvent, folder: FolderItem) {
@@ -2244,11 +2268,7 @@
{/if}
{#if shareDialog.component}
{@const ShareDialog = shareDialog.component}
<ShareDialog
bind:open={shareOpen}
item={actionTarget}
onshared={(id) => (sharedIds = new SvelteSet(sharedIds).add(id))}
/>
<ShareDialog bind:open={shareOpen} item={actionTarget} onshared={(id) => sharedIds.add(id)} />
{/if}
{#if fileViewer.component}
{@const FileViewer = fileViewer.component}
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest';
/**
* Benchmark gate for the files view's batch-operation rework
* (`batchDelete` / `moveInto` / `selectionTargets` / `batchDownload` in
* `[...path]/+page.svelte`).
*
* Audit finding: multi-item delete/move awaited one request per item in a
* serial loop — at ~30 ms RTT a 100-item delete is ~3 s of waterfall — and
* every per-id classification ran `listing.folders.find(...)` /
* `listing.files.some(...)`, an O(N·M) scan over the listing per selected id.
* The fix builds an id index once (O(M)) and fans the requests out through
* the view's existing `mapLimit` with 6 in flight.
*
* The functions are component-internal, so — like the Rust bench modules that
* replicate handler internals — this bench replicates BEFORE verbatim and
* AFTER (index + `mapLimit`, the exact shapes now in the component) against a
* stubbed per-item endpoint with simulated latency.
*
* Gates: (1) both arms attempt the identical (id, kind) operation set —
* folder-first classification preserved; (2) a 100-item batch at 5 ms
* simulated RTT completes ≥3x faster; (3) the classification scan count
* drops from O(N·M) to one pass.
*/
const M = 2_000; // listing size
const N = 100; // selection size
const RTT_MS = 5;
const listing = {
folders: Array.from({ length: M / 4 }, (_, i) => ({ id: `d-${i}`, name: `dir ${i}` })),
files: Array.from({ length: (3 * M) / 4 }, (_, i) => ({ id: `f-${i}`, name: `file ${i}` }))
};
// Selection interleaves folders and files, like a shift-range over a mixed view.
const selectedIds = [
...listing.folders.slice(40, 40 + N / 4).map((f) => f.id),
...listing.files.slice(900, 900 + (3 * N) / 4).map((f) => f.id)
];
/** Stubbed per-item endpoint: RTT_MS latency, records the attempted op. */
function makeOps() {
const attempted: Array<{ id: string; kind: 'file' | 'folder' }> = [];
let comparisons = 0;
return {
attempted,
countCmp: () => comparisons++,
get comparisons() {
return comparisons;
},
deleteFolder: async (id: string) => {
attempted.push({ id, kind: 'folder' });
await new Promise((r) => setTimeout(r, RTT_MS));
},
deleteFile: async (id: string) => {
attempted.push({ id, kind: 'file' });
await new Promise((r) => setTimeout(r, RTT_MS));
}
};
}
type Ops = ReturnType<typeof makeOps>;
/** BEFORE, verbatim shape: serial await + `find` per id. */
async function batchDeleteBefore(ids: string[], ops: Ops): Promise<void> {
for (const id of ids) {
const folder = listing.folders.find((f) => {
ops.countCmp();
return f.id === id;
});
if (folder) await ops.deleteFolder(id);
else await ops.deleteFile(id);
}
}
/** The view's `mapLimit`, verbatim. */
async function mapLimit<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const out = new Array<R>(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
out[i] = await fn(items[i]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return out;
}
/** AFTER, verbatim shape: one O(M) index pass + bounded fan-out of 6. */
async function batchDeleteAfter(ids: string[], ops: Ops): Promise<void> {
const folderIdSet = new Set(
listing.folders.map((f) => {
ops.countCmp();
return f.id;
})
);
await mapLimit(ids, 6, async (id) => {
if (folderIdSet.has(id)) await ops.deleteFolder(id);
else await ops.deleteFile(id);
});
}
const opKey = (o: { id: string; kind: string }) => `${o.kind}:${o.id}`;
describe('files-view batch operations (benchmark gate)', () => {
it(
'both arms attempt the identical operation set, ≥3x faster fanned out',
{ timeout: 30_000 },
async () => {
const before = makeOps();
const t0 = performance.now();
await batchDeleteBefore(selectedIds, before);
const beforeMs = performance.now() - t0;
const after = makeOps();
const t1 = performance.now();
await batchDeleteAfter(selectedIds, after);
const afterMs = performance.now() - t1;
// Equivalence: same ops, same folder/file classification. Order is
// not part of the contract (the ops are independent single-item
// endpoints); compare as sets and sizes.
expect(after.attempted.length).toBe(before.attempted.length);
expect(new Set(after.attempted.map(opKey))).toEqual(new Set(before.attempted.map(opKey)));
expect(before.attempted.filter((o) => o.kind === 'folder').length).toBe(N / 4);
// Scan work: O(N·M) probes collapse to one O(M) pass.
expect(after.comparisons).toBe(listing.folders.length);
expect(before.comparisons).toBeGreaterThan(after.comparisons * 10);
console.info(
`batch delete ${N} items @ ${RTT_MS} ms RTT: serial ${beforeMs.toFixed(0)} ms (${before.comparisons} id probes) vs mapLimit(6) ${afterMs.toFixed(0)} ms (${after.comparisons} probes) — ${(beforeMs / afterMs).toFixed(1)}x`
);
expect(afterMs).toBeLessThan(beforeMs / 3);
}
);
it('selectionTargets index matches the per-id find, folder-first on collision', () => {
// BEFORE: folder probed first per id. AFTER: files inserted first so
// folders overwrite → folder wins collisions. Same observable result.
const shadow = { id: listing.files[0].id, name: 'shadow-folder' };
const foldersPlus = [...listing.folders, shadow];
const wanted = [shadow.id, listing.folders[5].id, listing.files[10].id, 'missing-id'];
const beforeTargets = wanted
.map((id) => {
const folder = foldersPlus.find((f) => f.id === id);
if (folder) return { id, name: folder.name, kind: 'folder' as const };
const file = listing.files.find((f) => f.id === id);
return file ? { id, name: file.name, kind: 'file' as const } : null;
})
.filter((x): x is NonNullable<typeof x> => x !== null);
const byId = new Map<string, { id: string; name: string; kind: 'file' | 'folder' }>();
for (const f of listing.files) byId.set(f.id, { id: f.id, name: f.name, kind: 'file' });
for (const f of foldersPlus) byId.set(f.id, { id: f.id, name: f.name, kind: 'folder' });
const afterTargets = wanted
.map((id) => byId.get(id) ?? null)
.filter((x): x is NonNullable<typeof x> => x !== null);
expect(afterTargets).toEqual(beforeTargets);
});
});
+50 -141
View File
@@ -15,7 +15,14 @@
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { filterDotfiles } from '$lib/utils/dotfileFilter';
import { dateTimeFormatFor } from '$lib/utils/display';
import { isVideo, photoTimestamp } from '$lib/utils/media';
import {
PhotoTimeline,
type GroupMode,
type LayoutMode,
type PhotoRow
} from '$lib/utils/photoTimeline';
type Tab = 'moments' | 'places' | 'people';
let tab = $state<Tab>('moments');
@@ -48,8 +55,6 @@
/** Usable content width of the grid, for the justified layout. */
let gridWidth = $state(0);
type GroupMode = 'day' | 'month' | 'year';
type LayoutMode = 'square' | 'justified';
const GROUP_KEY = 'oxi-photos-group';
const LAYOUT_KEY = 'oxi-photos-layout';
let groupMode = $state<GroupMode>('month');
@@ -63,153 +68,57 @@
else if (tab === 'people') void peopleView.load();
});
/** EXIF-aware timestamp (seconds → ms), matching the OLD grouping logic. */
function bucketKey(d: Date): string {
const y = d.getFullYear();
if (groupMode === 'year') return `${y}`;
const m = `${d.getMonth() + 1}`.padStart(2, '0');
if (groupMode === 'month') return `${y}-${m}`;
return `${y}-${m}-${`${d.getDate()}`.padStart(2, '0')}`;
}
function bucketLabel(d: Date): string {
if (groupMode === 'year') return `${d.getFullYear()}`;
if (groupMode === 'month')
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
return d.toLocaleDateString(undefined, {
/** Locale-aware label for a bucket's representative date. */
function bucketLabel(d: Date, mode: GroupMode): string {
if (mode === 'year') return `${d.getFullYear()}`;
if (mode === 'month')
return dateTimeFormatFor(undefined, { year: 'numeric', month: 'long' }).format(d);
return dateTimeFormatFor(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
const groups = $derived.by(() => {
const out: Array<{ key: string; label: string; photos: PhotoItem[] }> = [];
// Transient scratch map built inside $derived.by and discarded — not reactive state.
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const index = new Map<string, number>();
for (const p of visibleItems) {
const d = new Date(photoTimestamp(p));
const key = bucketKey(d);
let i = index.get(key);
if (i === undefined) {
i = out.length;
index.set(key, i);
out.push({ key, label: bucketLabel(d), photos: [] });
}
out[i].photos.push(p);
}
return out;
});
interface JustifiedTile {
file: PhotoItem;
w: number;
h: number;
}
/**
* Pack files into justified rows (Flickr-style): each full row is scaled to
* fill `width` while preserving every tile's aspect ratio. Missing dimensions
* fall back to 1:1.
*/
function justifiedRows(
files: PhotoItem[],
width: number
): Array<{ height: number; tiles: JustifiedTile[] }> {
const gap = 8;
const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200;
const rows: Array<{ height: number; tiles: JustifiedTile[] }> = [];
let cur: Array<{ file: PhotoItem; aspect: number }> = [];
let aspectSum = 0;
for (const file of files) {
let aspect = file.width && file.height ? file.width / file.height : 1;
if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1;
aspect = Math.min(Math.max(aspect, 0.4), 3);
cur.push({ file, aspect });
aspectSum += aspect;
const rowWidth = aspectSum * target + (cur.length - 1) * gap;
if (rowWidth >= width) {
const h = (width - (cur.length - 1) * gap) / aspectSum;
rows.push({
height: Math.round(h),
tiles: cur.map((tt) => ({
file: tt.file,
w: Math.max(1, Math.round(tt.aspect * h)),
h: Math.round(h)
}))
});
cur = [];
aspectSum = 0;
}
}
if (cur.length) {
rows.push({
height: target,
tiles: cur.map((tt) => ({
file: tt.file,
w: Math.max(1, Math.round(tt.aspect * target)),
h: target
}))
});
}
return rows;
}).format(d);
}
// ── Virtualized row model ────────────────────────────────────────────────
// Flatten the groups into a single list of fixed-height rows (a date header
// or a strip of sized tiles), so VirtualRows can window the whole timeline —
// only the rows near the viewport are mounted, regardless of library size.
const SQUARE_GAP = 4; // .25rem, matches the old grid gap
const SQUARE_MIN = 144; // 9rem minmax floor
const JUSTIFIED_GAP = 8; // .photos-jrow margin-bottom
const HEADER_H = 44;
type PhotoRow =
| { kind: 'header'; key: string; height: number; label: string; count: number }
| { kind: 'tiles'; key: string; height: number; gap: number; tiles: JustifiedTile[] };
const photoRows = $derived.by<PhotoRow[]>(() => {
const W = gridWidth;
if (W <= 0) return [];
const rows: PhotoRow[] = [];
const cols = Math.max(1, Math.floor((W + SQUARE_GAP) / (SQUARE_MIN + SQUARE_GAP)));
const cell = (W - (cols - 1) * SQUARE_GAP) / cols;
for (const g of groups) {
rows.push({
kind: 'header',
key: `h:${g.key}`,
height: HEADER_H,
label: g.label,
count: g.photos.length
});
if (layoutMode === 'justified') {
const jrows = justifiedRows(g.photos, W);
for (let ri = 0; ri < jrows.length; ri++) {
rows.push({
kind: 'tiles',
key: `${g.key}:j${ri}`,
height: jrows[ri].height + JUSTIFIED_GAP,
gap: JUSTIFIED_GAP,
tiles: jrows[ri].tiles
});
}
} else {
for (let i = 0; i < g.photos.length; i += cols) {
const tiles = g.photos.slice(i, i + cols).map((file) => ({ file, w: cell, h: cell }));
rows.push({
kind: 'tiles',
key: `${g.key}:s${i}`,
height: cell + SQUARE_GAP,
gap: SQUARE_GAP,
tiles
});
}
}
}
return rows;
// Flatten the date groups into a single list of fixed-height rows (a header
// or a strip of sized tiles) that VirtualRows windows. Because pages arrive
// newest-first, each append only extends the last group or adds new ones, so
// PhotoTimeline re-buckets only the fresh page and re-lays-out only the
// groups that changed — a full scroll stays O(N), not O(N²) (the old
// `groups`→`photoRows` derive chain re-grouped + re-packed the whole library
// on every 60-item page). See photoGrouping.bench.test.ts.
// `sync` mutates the timeline's (non-reactive) internal group/row caches and
// returns the flat rows. Driven from `$derived.by` for idempotence: if the
// deps re-fire without an actual append, `sync` sees a non-growing list and
// safely full-rebuilds — same output as the pure `buildPhotoRows`.
const timeline = new PhotoTimeline();
// `mobile` as state fed by one MediaQueryList listener: the derive below
// re-runs on every page append, and `window.matchMedia(...)` inside it was
// a per-recompute style/layout read that only changes on viewport-class
// crossings — now those crossings push the boolean instead.
let isMobile = $state(false);
$effect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const mql = window.matchMedia('(max-width: 768px)');
isMobile = mql.matches;
const onchange = (e: MediaQueryListEvent) => {
isMobile = e.matches;
};
mql.addEventListener('change', onchange);
return () => mql.removeEventListener('change', onchange);
});
const photoRows = $derived.by<PhotoRow[]>(() =>
timeline.sync(visibleItems, {
groupMode,
layoutMode,
width: gridWidth,
mobile: isMobile,
timestampOf: photoTimestamp,
labelOf: bucketLabel
})
);
async function loadMore() {
if (loading || exhausted) return;
+13 -10
View File
@@ -28,6 +28,7 @@
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { preferences } from '$lib/stores/preferences.svelte';
import { filterDotfiles } from '$lib/utils/dotfileFilter';
import { replaceSet } from '$lib/utils/sets';
import { t } from '$lib/i18n/index.svelte';
let raw = $state<RecentResourceItem[]>([]);
@@ -37,7 +38,9 @@
let groupBy = $state('');
let reversed = $state(false);
const owners = useOwnerCache(resolveOwnerName);
let favoriteIds = $state<Set<string>>(new Set());
// In-place reactive set — a star toggle skips the full-set copy and
// spares the other favorited rows' readers.
const favoriteIds = new SvelteSet<string>();
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
@@ -109,7 +112,10 @@
async function loadFavoriteIds() {
try {
const favs = await fetchFavoritesPage({ resourceTypes: ['file', 'folder'] });
favoriteIds = new Set(favs.items.map((f) => f.resource.id));
replaceSet(
favoriteIds,
favs.items.map((f) => f.resource.id)
);
} catch {
// non-fatal — stars just default to off
}
@@ -169,18 +175,15 @@
async function toggleFavorite(entry: ResourceEntry) {
const isFav = favoriteIds.has(entry.id);
const next = new SvelteSet(favoriteIds);
if (isFav) next.delete(entry.id);
else next.add(entry.id);
favoriteIds = next;
// Optimistic in-place toggle, reverted on failure.
if (isFav) favoriteIds.delete(entry.id);
else favoriteIds.add(entry.id);
try {
if (isFav) await removeFavorite(entry.kind, entry.id);
else await addFavorite(entry.kind, entry.id);
} catch (e) {
// revert on failure
favoriteIds = isFav
? new Set([...favoriteIds, entry.id])
: new Set([...favoriteIds].filter((id) => id !== entry.id));
if (isFav) favoriteIds.add(entry.id);
else favoriteIds.delete(entry.id);
errorToast(e);
}
}
+4 -4
View File
@@ -27,7 +27,7 @@
import UserVignette from '$lib/components/UserVignette.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { iconNameFromClass } from '$lib/utils/display';
import { formatDate, iconNameFromClass } from '$lib/utils/display';
type GroupBy = 'items' | 'sharedWith';
@@ -158,9 +158,9 @@
}
function expiryLabel(iso: string | null | undefined): string {
if (!iso) return t('share.noExpiry', 'No expiry');
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
// Same semantics as before (`''` for unparseable dates), now via the
// shared util so it reuses the cached Intl.DateTimeFormat.
return formatDate(iso);
}
function isoToDate(iso: string | null | undefined): string {
return iso ? String(iso).slice(0, 10) : '';