From 01faec91557ff0bd92c4b3435e9d80e30fb58c8c Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 19:30:54 +0100 Subject: [PATCH] refactor(gallery): modularize gallery and timeline Break down the monolithic Gallery and Timeline components into smaller, highly cohesive modules to improve maintainability and separation of concerns. - Extract core UI elements into dedicated components (`ImageTile`, `TruncatedFilename`, `ScrubberYearBlock`). - Separate loading and empty states into dedicated files (`GalleryEmptyState`, `TimelineEmptyState`). - Move complex timeline grouping, row virtualization mapping, and duration formatting logic into utility files (`timelineModel.ts` and `format.ts`). - Abstract shared data structures into `types.ts` to cleanly decouple domain logic from the presentation layer. --- src/components/Gallery.tsx | 390 +++--------------- src/components/Timeline.tsx | 187 +-------- src/components/gallery/GalleryEmptyState.tsx | 76 ++++ src/components/gallery/ImageTile.tsx | 166 ++++++++ src/components/gallery/TruncatedFilename.tsx | 34 ++ src/components/gallery/format.ts | 11 + src/components/timeline/ScrubberYearBlock.tsx | 47 +++ .../timeline/TimelineEmptyState.tsx | 39 ++ src/components/timeline/timelineModel.ts | 71 ++++ src/components/timeline/types.ts | 29 ++ 10 files changed, 549 insertions(+), 501 deletions(-) create mode 100644 src/components/gallery/GalleryEmptyState.tsx create mode 100644 src/components/gallery/ImageTile.tsx create mode 100644 src/components/gallery/TruncatedFilename.tsx create mode 100644 src/components/gallery/format.ts create mode 100644 src/components/timeline/ScrubberYearBlock.tsx create mode 100644 src/components/timeline/TimelineEmptyState.tsx create mode 100644 src/components/timeline/timelineModel.ts create mode 100644 src/components/timeline/types.ts diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index 2160fae..2c8a8eb 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -3,225 +3,11 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store"; import { BulkActionBar } from "./BulkActionBar"; import { ImageContextMenu } from "./ImageContextMenu"; -import { Tooltip } from "./Tooltip"; -import { mediaSrc } from "../lib/mediaSrc"; -import { CheckIcon, PhotoIcon, PlayIcon, StarIcon, WarningIcon } from "./icons"; +import { GalleryEmptyState, GalleryLoadingState } from "./gallery/GalleryEmptyState"; +import { ImageTile } from "./gallery/ImageTile"; const GAP = 6; -function formatDuration(durationMs: number | null): string | null { - if (!durationMs || durationMs <= 0) return null; - const totalSeconds = Math.floor(durationMs / 1000); - const seconds = totalSeconds % 60; - const minutes = Math.floor(totalSeconds / 60) % 60; - const hours = Math.floor(totalSeconds / 3600); - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; - } - return `${minutes}:${seconds.toString().padStart(2, "0")}`; -} - -export function ImageTile({ - image, - onClick, - onContextMenu, -}: { - image: ImageRecord; - onClick: () => void; - onContextMenu: (event: React.MouseEvent) => void; -}) { - const [loaded, setLoaded] = useState(false); - const [errored, setErrored] = useState(false); - const findSimilar = useGalleryStore((state) => state.findSimilar); - const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id)); - const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0); - const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected); - const canFindSimilar = image.embedding_status === "ready"; - - const src = mediaSrc(image.thumbnail_path); - - return ( -
- {/* Full-tile click target — opens, or toggles selection while selecting. - A real button (over the non-interactive tile div) keeps it keyboard- - accessible without nesting buttons. */} - - {/* Image / placeholder */} - {src && !errored ? ( - <> - {!loaded &&
} - {image.filename} setLoaded(true)} - onError={() => setErrored(true)} - /> - - ) : ( -
- {image.media_kind === "video" ? ( - - ) : ( - - )} -
- )} - - {/* Video play icon — subtle at rest, visible on hover */} - {image.media_kind === "video" && ( -
-
- -
-
- )} - - {/* Persistent badges — only shown when meaningful */} -
- {image.embedding_status === "failed" && ( - -
- -
-
- )} - {image.favorite && ( -
- - - -
- )} - {image.rating > 0 && ( -
- {Array.from({ length: image.rating }, (_, index) => ( - - ))} -
- )} - {image.media_kind === "video" && image.duration_ms && ( -
- {formatDuration(image.duration_ms)} -
- )} -
- - {/* Hover overlay — slides up from bottom */} -
- - {/* Hover info — appears with overlay */} -
- -
- {image.rating > 0 ? ( -
- {Array.from({ length: image.rating }, (_, i) => ( - - ))} -
- ) : ( - - )} - -
-
-
- ); -} - -function TruncatedFilename({ filename }: { filename: string }) { - const textRef = useRef(null); - const [isTruncated, setIsTruncated] = useState(false); - - useLayoutEffect(() => { - const text = textRef.current; - if (!text) return; - - const update = () => { - setIsTruncated(text.scrollWidth > text.clientWidth); - }; - - update(); - - const observer = new ResizeObserver(update); - observer.observe(text); - return () => observer.disconnect(); - }, [filename]); - - const label = ( -

- {filename} -

- ); - - return ( - - {label} - - ); -} - export function Gallery() { const images = useGalleryStore((state) => state.images); const loadMoreImages = useGalleryStore((state) => state.loadMoreImages); @@ -294,118 +80,74 @@ export function Gallery() { }, [handleScroll]); return ( -
-
- {images.length === 0 && loadingImages ? ( -
-
-
-

- {isSimilarResults - ? "Finding similar images" - : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 - ? `Searching for matches to "${parsedSearch.query}"` - : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 - ? `Searching tags for "${parsedSearch.query}"` - : "Loading media"} -

-

- {isSimilarResults - ? "Comparing visual embeddings" - : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 - ? "Semantic search can take a little longer than filename search" - : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 - ? "Matching against AI and user tags" - : "Fetching results"} -

+
+
+ {images.length === 0 && loadingImages ? ( + + ) : images.length === 0 && !loadingImages ? ( + + ) : ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const startIndex = virtualRow.index * cols; + const rowImages = images.slice(startIndex, startIndex + cols); + return ( +
+ {rowImages.map((image) => ( + openImage(image)} + onContextMenu={(event) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, image }); + }} + /> + ))} +
+ ); + })}
-
- ) : images.length === 0 && !loadingImages ? ( -
-
- -

- {imageLoadError - ? "Could not load results" - : isSimilarResults - ? "No similar images found" - : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 - ? "No semantic matches found" - : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 - ? "No tag matches found" - : "No media found"} -

-

- {imageLoadError - ? imageLoadError - : isSimilarResults - ? "This item may be visually isolated, or more embeddings may need to finish processing" - : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 - ? "Try a broader phrase, or wait for more embeddings to finish processing" - : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 - ? "Try a shorter tag, or wait for more tagging jobs to finish" - : "Try adjusting your filters or add a new folder"} -

+ )} + + {images.length > 0 && loadingImages ? ( +
+
-
- ) : ( -
- {virtualizer.getVirtualItems().map((virtualRow) => { - const startIndex = virtualRow.index * cols; - const rowImages = images.slice(startIndex, startIndex + cols); - return ( -
- {rowImages.map((image) => ( - openImage(image)} - onContextMenu={(event) => { - event.preventDefault(); - setContextMenu({ x: event.clientX, y: event.clientY, image }); - }} - /> - ))} -
- ); - })} -
- )} + ) : null} - {images.length > 0 && loadingImages ? ( -
-
-
- ) : null} + {contextMenu ? ( + setContextMenu(null)} + /> + ) : null} +
- {contextMenu ? ( - setContextMenu(null)} - /> - ) : null} -
- - {/* Pinned to the bottom of the gallery viewport — outside the scroll - container so it stays put while the grid scrolls. */} - + {/* Pinned to the bottom of the gallery viewport — outside the scroll + container so it stays put while the grid scrolls. */} +
); } diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index 80b3137..48093ec 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -1,90 +1,15 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store"; -import { ImageTile } from "./Gallery"; +import { ImageTile } from "./gallery/ImageTile"; import { ImageContextMenu } from "./ImageContextMenu"; -import { Tooltip } from "./Tooltip"; +import { ScrubberYearBlock } from "./timeline/ScrubberYearBlock"; +import { TimelineEmptyState, TimelineLoadingState } from "./timeline/TimelineEmptyState"; +import { buildScrubberYears, buildTimelineRows, groupImages } from "./timeline/timelineModel"; const GAP = 6; const HEADER_HEIGHT = 52; const SCRUBBER_WIDTH = 48; -const MONTH_SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const; - -interface TimelineGroup { - key: string; - label: string; - images: ImageRecord[]; -} - -// One virtualized row: either a month header or a row of up to `cols` tiles. -type TimelineRow = - | { type: "header"; group: TimelineGroup } - | { type: "tiles"; images: ImageRecord[] }; - -interface ScrubberMonth { - monthNum: number; - label: string; - groupIndex: number; -} - -interface ScrubberYear { - year: string; - firstGroupIndex: number; - months: ScrubberMonth[]; -} - -function buildLabel(key: string): string { - if (key === "unknown") return "Unknown Date"; - const [yearStr, monthStr] = key.split("-"); - const year = Number(yearStr); - const month = Number(monthStr); - if (!isFinite(year) || !isFinite(month) || month < 1 || month > 12) return "Unknown Date"; - const date = new Date(year, month - 1); - if (isNaN(date.getTime())) return "Unknown Date"; - return date.toLocaleDateString(undefined, { month: "long", year: "numeric" }); -} - -function groupImages(images: ImageRecord[]): TimelineGroup[] { - const map = new Map(); - for (const img of images) { - const ds = img.taken_at ?? img.modified_at; - const key = ds ? ds.substring(0, 7) : "unknown"; - let bucket = map.get(key); - if (bucket === undefined) { - bucket = []; - map.set(key, bucket); - } - bucket.push(img); - } - return Array.from(map.entries()) - .sort(([a], [b]) => { - if (a === "unknown") return 1; - if (b === "unknown") return -1; - return a < b ? -1 : a > b ? 1 : 0; - }) - .map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs })); -} - -function buildScrubberYears(groups: TimelineGroup[]): ScrubberYear[] { - const byYear = new Map(); - for (let i = 0; i < groups.length; i++) { - const group = groups[i]; - if (group.key === "unknown") continue; - const year = group.key.substring(0, 4); - const monthNum = Number(group.key.substring(5, 7)); - if (!byYear.has(year)) { - byYear.set(year, { year, firstGroupIndex: i, months: [] }); - } - byYear.get(year)!.months.push({ - monthNum, - label: MONTH_SHORT[monthNum - 1] ?? "", - groupIndex: i, - }); - } - // Keep insertion order so the scrubber runs the same direction as the scrolled - // content (oldest at top with taken_asc), keeping the active highlight aligned. - return Array.from(byYear.values()); -} export function Timeline() { const images = useGalleryStore((s) => s.images); @@ -130,26 +55,10 @@ export function Timeline() { [containerWidth, tileSize], ); - // Flatten the month groups into a single list of fixed-height rows — one - // header row per group, then one tile-row per `cols` images. This lets the - // virtualizer render only the on-screen rows, exactly like the Gallery. - // Previously each *group* was one virtual item that rendered ALL of its - // images, so scrolling into a busy month mounted thousands of tiles at once. - const { rows, rowToGroupIndex, groupFirstRow } = useMemo(() => { - const rows: TimelineRow[] = []; - const rowToGroupIndex: number[] = []; - const groupFirstRow: number[] = []; - groups.forEach((group, groupIndex) => { - groupFirstRow[groupIndex] = rows.length; - rows.push({ type: "header", group }); - rowToGroupIndex.push(groupIndex); - for (let i = 0; i < group.images.length; i += cols) { - rows.push({ type: "tiles", images: group.images.slice(i, i + cols) }); - rowToGroupIndex.push(groupIndex); - } - }); - return { rows, rowToGroupIndex, groupFirstRow }; - }, [groups, cols]); + const { rows, rowToGroupIndex, groupFirstRow } = useMemo( + () => buildTimelineRows(groups, cols), + [groups, cols], + ); const estimateSize = useCallback( (index: number): number => @@ -223,37 +132,9 @@ export function Timeline() { className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0" > {images.length === 0 && loadingImages ? ( -
-
-
-

Loading timeline

-

Fetching results

-
-
+ ) : images.length === 0 ? ( -
-
- - - -

- {imageLoadError ? "Could not load timeline" : "No media found"} -

-

- {imageLoadError ?? "Add a folder to see your timeline"} -

-
-
+ ) : (
{virtualizer.getVirtualItems().map((virtualItem) => { @@ -348,51 +229,3 @@ export function Timeline() {
); } - -interface ScrubberYearBlockProps { - yearEntry: ScrubberYear; - activeGroupIndex: number; - onScrollTo: (index: number) => void; -} - -function ScrubberYearBlock({ yearEntry, activeGroupIndex, onScrollTo }: ScrubberYearBlockProps) { - const isYearActive = yearEntry.months.some((m) => m.groupIndex === activeGroupIndex); - - return ( -
- - - -
- {Array.from({ length: 12 }, (_, i) => { - const monthNum = i + 1; - const monthEntry = yearEntry.months.find((m) => m.monthNum === monthNum); - const isActive = monthEntry !== undefined && monthEntry.groupIndex === activeGroupIndex; - if (!monthEntry) { - return ; - } - return ( - -
-
- ); -} diff --git a/src/components/gallery/GalleryEmptyState.tsx b/src/components/gallery/GalleryEmptyState.tsx new file mode 100644 index 0000000..88212d2 --- /dev/null +++ b/src/components/gallery/GalleryEmptyState.tsx @@ -0,0 +1,76 @@ +import { ParsedSearch } from "../../store"; +import { PhotoIcon } from "../icons"; + +export function GalleryLoadingState({ + isSimilarResults, + parsedSearch, +}: { + isSimilarResults: boolean; + parsedSearch: ParsedSearch; +}) { + return ( +
+
+
+

+ {isSimilarResults + ? "Finding similar images" + : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 + ? `Searching for matches to "${parsedSearch.query}"` + : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 + ? `Searching tags for "${parsedSearch.query}"` + : "Loading media"} +

+

+ {isSimilarResults + ? "Comparing visual embeddings" + : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 + ? "Semantic search can take a little longer than filename search" + : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 + ? "Matching against AI and user tags" + : "Fetching results"} +

+
+
+ ); +} + +export function GalleryEmptyState({ + imageLoadError, + isSimilarResults, + parsedSearch, +}: { + imageLoadError: string | null; + isSimilarResults: boolean; + parsedSearch: ParsedSearch; +}) { + return ( +
+
+ +

+ {imageLoadError + ? "Could not load results" + : isSimilarResults + ? "No similar images found" + : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 + ? "No semantic matches found" + : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 + ? "No tag matches found" + : "No media found"} +

+

+ {imageLoadError + ? imageLoadError + : isSimilarResults + ? "This item may be visually isolated, or more embeddings may need to finish processing" + : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 + ? "Try a broader phrase, or wait for more embeddings to finish processing" + : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 + ? "Try a shorter tag, or wait for more tagging jobs to finish" + : "Try adjusting your filters or add a new folder"} +

+
+
+ ); +} diff --git a/src/components/gallery/ImageTile.tsx b/src/components/gallery/ImageTile.tsx new file mode 100644 index 0000000..846acd4 --- /dev/null +++ b/src/components/gallery/ImageTile.tsx @@ -0,0 +1,166 @@ +import { useState } from "react"; +import { ImageRecord, useGalleryStore } from "../../store"; +import { mediaSrc } from "../../lib/mediaSrc"; +import { Tooltip } from "../Tooltip"; +import { CheckIcon, PhotoIcon, PlayIcon, StarIcon, WarningIcon } from "../icons"; +import { formatDuration } from "./format"; +import { TruncatedFilename } from "./TruncatedFilename"; + +export function ImageTile({ + image, + onClick, + onContextMenu, +}: { + image: ImageRecord; + onClick: () => void; + onContextMenu: (event: React.MouseEvent) => void; +}) { + const [loaded, setLoaded] = useState(false); + const [errored, setErrored] = useState(false); + const findSimilar = useGalleryStore((state) => state.findSimilar); + const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id)); + const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0); + const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected); + const canFindSimilar = image.embedding_status === "ready"; + + const src = mediaSrc(image.thumbnail_path); + + return ( +
+ + + {src && !errored ? ( + <> + {!loaded ?
: null} + {image.filename} setLoaded(true)} + onError={() => setErrored(true)} + /> + + ) : ( +
+ {image.media_kind === "video" ? ( + + ) : ( + + )} +
+ )} + + {image.media_kind === "video" ? ( +
+
+ +
+
+ ) : null} + +
+ {image.embedding_status === "failed" ? ( + +
+ +
+
+ ) : null} + {image.favorite ? ( +
+ + + +
+ ) : null} + {image.rating > 0 ? ( +
+ {Array.from({ length: image.rating }, (_, index) => ( + + ))} +
+ ) : null} + {image.media_kind === "video" && image.duration_ms ? ( +
+ {formatDuration(image.duration_ms)} +
+ ) : null} +
+ +
+ +
+ +
+ {image.rating > 0 ? ( +
+ {Array.from({ length: image.rating }, (_, index) => ( + + ))} +
+ ) : ( + + )} + +
+
+
+ ); +} diff --git a/src/components/gallery/TruncatedFilename.tsx b/src/components/gallery/TruncatedFilename.tsx new file mode 100644 index 0000000..334374d --- /dev/null +++ b/src/components/gallery/TruncatedFilename.tsx @@ -0,0 +1,34 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { Tooltip } from "../Tooltip"; + +export function TruncatedFilename({ filename }: { filename: string }) { + const textRef = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + + useLayoutEffect(() => { + const text = textRef.current; + if (!text) return; + + const update = () => { + setIsTruncated(text.scrollWidth > text.clientWidth); + }; + + update(); + + const observer = new ResizeObserver(update); + observer.observe(text); + return () => observer.disconnect(); + }, [filename]); + + const label = ( +

+ {filename} +

+ ); + + return ( + + {label} + + ); +} diff --git a/src/components/gallery/format.ts b/src/components/gallery/format.ts new file mode 100644 index 0000000..326ba57 --- /dev/null +++ b/src/components/gallery/format.ts @@ -0,0 +1,11 @@ +export function formatDuration(durationMs: number | null): string | null { + if (!durationMs || durationMs <= 0) return null; + const totalSeconds = Math.floor(durationMs / 1000); + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60) % 60; + const hours = Math.floor(totalSeconds / 3600); + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} diff --git a/src/components/timeline/ScrubberYearBlock.tsx b/src/components/timeline/ScrubberYearBlock.tsx new file mode 100644 index 0000000..89cfe1a --- /dev/null +++ b/src/components/timeline/ScrubberYearBlock.tsx @@ -0,0 +1,47 @@ +import { Tooltip } from "../Tooltip"; +import { ScrubberYear } from "./types"; + +interface ScrubberYearBlockProps { + yearEntry: ScrubberYear; + activeGroupIndex: number; + onScrollTo: (index: number) => void; +} + +export function ScrubberYearBlock({ yearEntry, activeGroupIndex, onScrollTo }: ScrubberYearBlockProps) { + const isYearActive = yearEntry.months.some((month) => month.groupIndex === activeGroupIndex); + + return ( +
+ + + +
+ {Array.from({ length: 12 }, (_, index) => { + const monthNum = index + 1; + const monthEntry = yearEntry.months.find((month) => month.monthNum === monthNum); + const isActive = monthEntry !== undefined && monthEntry.groupIndex === activeGroupIndex; + if (!monthEntry) { + return ; + } + return ( + +
+
+ ); +} diff --git a/src/components/timeline/TimelineEmptyState.tsx b/src/components/timeline/TimelineEmptyState.tsx new file mode 100644 index 0000000..2c9481b --- /dev/null +++ b/src/components/timeline/TimelineEmptyState.tsx @@ -0,0 +1,39 @@ +export function TimelineLoadingState() { + return ( +
+
+
+

Loading timeline

+

Fetching results

+
+
+ ); +} + +export function TimelineEmptyState({ imageLoadError }: { imageLoadError: string | null }) { + return ( +
+
+ + + +

+ {imageLoadError ? "Could not load timeline" : "No media found"} +

+

+ {imageLoadError ?? "Add a folder to see your timeline"} +

+
+
+ ); +} diff --git a/src/components/timeline/timelineModel.ts b/src/components/timeline/timelineModel.ts new file mode 100644 index 0000000..474d7d9 --- /dev/null +++ b/src/components/timeline/timelineModel.ts @@ -0,0 +1,71 @@ +import { ImageRecord } from "../../store"; +import { ScrubberYear, TimelineGroup, TimelineRow, TimelineRows } from "./types"; + +const MONTH_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const; + +function buildLabel(key: string): string { + if (key === "unknown") return "Unknown Date"; + const [yearStr, monthStr] = key.split("-"); + const year = Number(yearStr); + const month = Number(monthStr); + if (!isFinite(year) || !isFinite(month) || month < 1 || month > 12) return "Unknown Date"; + const date = new Date(year, month - 1); + if (isNaN(date.getTime())) return "Unknown Date"; + return date.toLocaleDateString(undefined, { month: "long", year: "numeric" }); +} + +export function groupImages(images: ImageRecord[]): TimelineGroup[] { + const map = new Map(); + for (const image of images) { + const dateString = image.taken_at ?? image.modified_at; + const key = dateString ? dateString.substring(0, 7) : "unknown"; + let bucket = map.get(key); + if (bucket === undefined) { + bucket = []; + map.set(key, bucket); + } + bucket.push(image); + } + return Array.from(map.entries()) + .sort(([a], [b]) => { + if (a === "unknown") return 1; + if (b === "unknown") return -1; + return a < b ? -1 : a > b ? 1 : 0; + }) + .map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs })); +} + +export function buildScrubberYears(groups: TimelineGroup[]): ScrubberYear[] { + const byYear = new Map(); + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + if (group.key === "unknown") continue; + const year = group.key.substring(0, 4); + const monthNum = Number(group.key.substring(5, 7)); + if (!byYear.has(year)) { + byYear.set(year, { year, firstGroupIndex: i, months: [] }); + } + byYear.get(year)!.months.push({ + monthNum, + label: MONTH_SHORT[monthNum - 1] ?? "", + groupIndex: i, + }); + } + return Array.from(byYear.values()); +} + +export function buildTimelineRows(groups: TimelineGroup[], cols: number): TimelineRows { + const rows: TimelineRow[] = []; + const rowToGroupIndex: number[] = []; + const groupFirstRow: number[] = []; + groups.forEach((group, groupIndex) => { + groupFirstRow[groupIndex] = rows.length; + rows.push({ type: "header", group }); + rowToGroupIndex.push(groupIndex); + for (let i = 0; i < group.images.length; i += cols) { + rows.push({ type: "tiles", images: group.images.slice(i, i + cols) }); + rowToGroupIndex.push(groupIndex); + } + }); + return { rows, rowToGroupIndex, groupFirstRow }; +} diff --git a/src/components/timeline/types.ts b/src/components/timeline/types.ts new file mode 100644 index 0000000..878f452 --- /dev/null +++ b/src/components/timeline/types.ts @@ -0,0 +1,29 @@ +import { ImageRecord } from "../../store"; + +export interface TimelineGroup { + key: string; + label: string; + images: ImageRecord[]; +} + +export type TimelineRow = + | { type: "header"; group: TimelineGroup } + | { type: "tiles"; images: ImageRecord[] }; + +export interface ScrubberMonth { + monthNum: number; + label: string; + groupIndex: number; +} + +export interface ScrubberYear { + year: string; + firstGroupIndex: number; + months: ScrubberMonth[]; +} + +export interface TimelineRows { + rows: TimelineRow[]; + rowToGroupIndex: number[]; + groupFirstRow: number[]; +}