Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 | 1758x 1220x 1209x 11x 5x 5x 5x 5x 391x 22x 32x 32x 38x 35x 15x 74x 32x 32x 28x 2x 26x 10x 16x 16x | import type { Item } from "@/types";
type NameableItem = { name: string | null; number: number | null };
/** True when an item has a non-blank name. The one definition of "named" —
* display, filter, and the "add a name" cues all agree (a whitespace-only
* name counts as nameless). */
export function hasName(item: { name: string | null }): boolean {
return !!item.name?.trim();
}
/** Human label for an item: its name, else its catalog number (#N), else "Untitled". */
export function itemDisplayName(item: NameableItem): string {
if (hasName(item)) {
return item.name!.trim();
}
return item.number != null ? `#${item.number}` : "Untitled";
}
/** "#14 · added in June" — the year appears only when it isn't the current one. */
export function itemSubtitle(item: { number: number | null; createdAt: Date }, now: Date): string {
const d = new Date(item.createdAt);
const month = d.toLocaleString("en-US", { month: "long", timeZone: "UTC" });
const when = d.getUTCFullYear() === now.getUTCFullYear() ? month : `${month} ${d.getUTCFullYear()}`;
return `${item.number != null ? `#${item.number} · ` : ""}added in ${when}`;
}
/** Single-glyph avatar for an item with no photo: name initial, else a neutral mark. */
export function itemInitial(item: NameableItem): string {
return hasName(item) ? item.name!.trim().charAt(0).toUpperCase() : "•";
}
const SEARCHABLE = ["name", "brand", "style", "color", "material", "category"] as const;
/** Grid filter: plain-text search (substring, case-insensitive — never a pattern)
* and the "Waiting for a name" view. `#N` matches an item's catalog number. */
export function filterItems(items: Item[], opts: { unnamed: boolean; q: string }): Item[] {
const needle = opts.q.trim().toLocaleLowerCase();
return items.filter((item) => {
if (opts.unnamed && hasName(item)) return false;
if (!needle) return true;
return (
SEARCHABLE.some((k) => item[k]?.toLocaleLowerCase().includes(needle)) ||
(item.number != null && `#${item.number}` === needle)
);
});
}
/** Heading + message for the items grid's empty states. Kept pure + tested so
* the gentle, no-shame copy can't silently regress (TONE: never count what's
* missing, no "incomplete"/"need" framing). */
export function itemsEmptyState(opts: {
hasAnyItems: boolean;
hasArchived?: boolean;
view: "all" | "archived" | "unnamed";
q: string;
}): { heading: string; message: string } {
const q = opts.q.trim();
if (q) return { heading: `Nothing matches “${q}”`, message: "Try another word, or clear the search." };
if (opts.view === "archived") {
return { heading: "Nothing archived", message: "Archived items rest here, ready to come back whenever you like." };
}
if (!opts.hasAnyItems) {
return opts.hasArchived
? { heading: "Everything here is archived", message: "Find it under Archived." }
: { heading: "No items yet", message: "Add photos to start your collection." };
}
Iif (opts.view === "unnamed") {
return { heading: "Everything has a name", message: "Nothing is waiting — name things whenever you like." };
}
return { heading: "No items here", message: "Try a different filter." };
}
|