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.
This commit is contained in:
+66
-324
@@ -3,225 +3,11 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
|||||||
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
|
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
|
||||||
import { BulkActionBar } from "./BulkActionBar";
|
import { BulkActionBar } from "./BulkActionBar";
|
||||||
import { ImageContextMenu } from "./ImageContextMenu";
|
import { ImageContextMenu } from "./ImageContextMenu";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { GalleryEmptyState, GalleryLoadingState } from "./gallery/GalleryEmptyState";
|
||||||
import { mediaSrc } from "../lib/mediaSrc";
|
import { ImageTile } from "./gallery/ImageTile";
|
||||||
import { CheckIcon, PhotoIcon, PlayIcon, StarIcon, WarningIcon } from "./icons";
|
|
||||||
|
|
||||||
const GAP = 6;
|
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<HTMLDivElement>) => 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 (
|
|
||||||
<div
|
|
||||||
className={`media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none transition-shadow ${
|
|
||||||
selected ? "ring-2 ring-inset ring-blue-400/80" : ""
|
|
||||||
}`}
|
|
||||||
style={{ width: "100%", aspectRatio: "1 / 1" }}
|
|
||||||
onContextMenu={onContextMenu}
|
|
||||||
>
|
|
||||||
{/* 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. */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute inset-0 z-10 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
|
|
||||||
aria-label={`Open ${image.filename}`}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (selectionActive) toggleGallerySelected(image.id);
|
|
||||||
else onClick();
|
|
||||||
}}
|
|
||||||
onDoubleClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onClick();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Selection corner — a top-left zone that reveals the checkbox only when
|
|
||||||
hovered (not the whole tile) and toggles selection on click. The
|
|
||||||
checkbox stays visible once the item is selected. */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="checkbox"
|
|
||||||
aria-checked={selected}
|
|
||||||
aria-label={selected ? "Deselect" : "Select"}
|
|
||||||
className="group/cb absolute top-0 left-0 z-20 h-11 w-11 cursor-pointer"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
toggleGallerySelected(image.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`absolute top-2 left-2 flex h-5 w-5 items-center justify-center rounded-full border transition-all duration-150 ${
|
|
||||||
selected
|
|
||||||
? "border-blue-400 bg-blue-500 text-white opacity-100"
|
|
||||||
: "border-white/70 bg-black/40 text-transparent opacity-0 backdrop-blur-sm group-hover/cb:opacity-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<CheckIcon className="h-3 w-3" strokeWidth={3} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/* Image / placeholder */}
|
|
||||||
{src && !errored ? (
|
|
||||||
<>
|
|
||||||
{!loaded && <div className="absolute inset-0 animate-pulse bg-white/[0.04]" />}
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={image.filename}
|
|
||||||
className={`h-full w-full object-cover transition-all duration-300 ${
|
|
||||||
loaded ? "opacity-100 scale-100" : "opacity-0 scale-[1.02]"
|
|
||||||
} group-hover:scale-[1.03]`}
|
|
||||||
loading="lazy"
|
|
||||||
onLoad={() => setLoaded(true)}
|
|
||||||
onError={() => setErrored(true)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/[0.03] text-white/20">
|
|
||||||
{image.media_kind === "video" ? (
|
|
||||||
<PlayIcon className="h-7 w-7" />
|
|
||||||
) : (
|
|
||||||
<PhotoIcon className="h-7 w-7" strokeWidth={1} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Video play icon — subtle at rest, visible on hover */}
|
|
||||||
{image.media_kind === "video" && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
||||||
<div className="rounded-full bg-black/40 p-3 text-white backdrop-blur-sm opacity-50 group-hover:opacity-90 transition-opacity duration-200">
|
|
||||||
<PlayIcon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Persistent badges — only shown when meaningful */}
|
|
||||||
<div className="absolute top-2 right-2 flex flex-col items-end gap-1 pointer-events-none">
|
|
||||||
{image.embedding_status === "failed" && (
|
|
||||||
<Tooltip label={image.embedding_error ?? "Embedding failed"} followCursor className="pointer-events-auto">
|
|
||||||
<div className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm">
|
|
||||||
<WarningIcon className="h-2.5 w-2.5 shrink-0" strokeWidth={2.5} />
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{image.favorite && (
|
|
||||||
<div className="rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
|
|
||||||
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{image.rating > 0 && (
|
|
||||||
<div className="flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-1 text-amber-300 backdrop-blur-sm">
|
|
||||||
{Array.from({ length: image.rating }, (_, index) => (
|
|
||||||
<StarIcon key={index} className="h-2.5 w-2.5" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{image.media_kind === "video" && image.duration_ms && (
|
|
||||||
<div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm">
|
|
||||||
{formatDuration(image.duration_ms)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hover overlay — slides up from bottom */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
|
|
||||||
|
|
||||||
{/* Hover info — appears with overlay */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 z-20 p-2.5 translate-y-1 group-hover:translate-y-0 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
|
||||||
<TruncatedFilename filename={image.filename} />
|
|
||||||
<div className="mt-1.5 flex items-center justify-between gap-2">
|
|
||||||
{image.rating > 0 ? (
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
{Array.from({ length: image.rating }, (_, i) => (
|
|
||||||
<StarIcon key={i} className="h-2.5 w-2.5 text-amber-300" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className={`relative z-20 rounded-md px-2 py-0.5 text-[10px] transition-colors pointer-events-auto backdrop-blur-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80 ${
|
|
||||||
canFindSimilar
|
|
||||||
? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white"
|
|
||||||
: "bg-white/5 text-white/30 cursor-not-allowed"
|
|
||||||
}`}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (!canFindSimilar) return;
|
|
||||||
findSimilar(image.id, image.folder_id);
|
|
||||||
}}
|
|
||||||
disabled={!canFindSimilar}
|
|
||||||
>
|
|
||||||
Similar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TruncatedFilename({ filename }: { filename: string }) {
|
|
||||||
const textRef = useRef<HTMLParagraphElement>(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 = (
|
|
||||||
<p ref={textRef} className="truncate text-[12px] font-medium leading-tight text-white">
|
|
||||||
{filename}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label={filename} delay={500} block followCursor disabled={!isTruncated}>
|
|
||||||
{label}
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Gallery() {
|
export function Gallery() {
|
||||||
const images = useGalleryStore((state) => state.images);
|
const images = useGalleryStore((state) => state.images);
|
||||||
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages);
|
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages);
|
||||||
@@ -294,118 +80,74 @@ export function Gallery() {
|
|||||||
}, [handleScroll]);
|
}, [handleScroll]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 min-h-0">
|
<div className="relative min-h-0 flex-1">
|
||||||
<div ref={parentRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden bg-gray-950">
|
<div ref={parentRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden bg-gray-950">
|
||||||
{images.length === 0 && loadingImages ? (
|
{images.length === 0 && loadingImages ? (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
|
<GalleryLoadingState isSimilarResults={isSimilarResults} parsedSearch={parsedSearch} />
|
||||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
|
) : images.length === 0 && !loadingImages ? (
|
||||||
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
|
<GalleryEmptyState
|
||||||
<p className="mt-4 text-sm text-white/40 font-medium">
|
imageLoadError={imageLoadError}
|
||||||
{isSimilarResults
|
isSimilarResults={isSimilarResults}
|
||||||
? "Finding similar images"
|
parsedSearch={parsedSearch}
|
||||||
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
|
/>
|
||||||
? `Searching for matches to "${parsedSearch.query}"`
|
) : (
|
||||||
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
|
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||||
? `Searching tags for "${parsedSearch.query}"`
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
: "Loading media"}
|
const startIndex = virtualRow.index * cols;
|
||||||
</p>
|
const rowImages = images.slice(startIndex, startIndex + cols);
|
||||||
<p className="text-xs text-white/20 mt-1">
|
return (
|
||||||
{isSimilarResults
|
<div
|
||||||
? "Comparing visual embeddings"
|
key={virtualRow.key}
|
||||||
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
|
style={{
|
||||||
? "Semantic search can take a little longer than filename search"
|
position: "absolute",
|
||||||
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
|
top: virtualRow.start,
|
||||||
? "Matching against AI and user tags"
|
width: "100%",
|
||||||
: "Fetching results"}
|
height: virtualRow.size,
|
||||||
</p>
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
|
||||||
|
gap: GAP,
|
||||||
|
paddingLeft: GAP,
|
||||||
|
paddingRight: GAP,
|
||||||
|
paddingBottom: GAP,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowImages.map((image) => (
|
||||||
|
<ImageTile
|
||||||
|
key={image.id}
|
||||||
|
image={image}
|
||||||
|
onClick={() => openImage(image)}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setContextMenu({ x: event.clientX, y: event.clientY, image });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
) : images.length === 0 && !loadingImages ? (
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
|
{images.length > 0 && loadingImages ? (
|
||||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
|
<div className="flex justify-center py-8">
|
||||||
<PhotoIcon className="h-12 w-12 mx-auto text-white/10 mb-4" strokeWidth={0.75} />
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/20 border-t-white/60" />
|
||||||
<p className="text-sm text-white/30 font-medium">
|
|
||||||
{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"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-white/15 mt-1">
|
|
||||||
{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"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : (
|
|
||||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
||||||
const startIndex = virtualRow.index * cols;
|
|
||||||
const rowImages = images.slice(startIndex, startIndex + cols);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={virtualRow.key}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: virtualRow.start,
|
|
||||||
width: "100%",
|
|
||||||
height: virtualRow.size,
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
|
|
||||||
gap: GAP,
|
|
||||||
paddingLeft: GAP,
|
|
||||||
paddingRight: GAP,
|
|
||||||
paddingBottom: GAP,
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rowImages.map((image) => (
|
|
||||||
<ImageTile
|
|
||||||
key={image.id}
|
|
||||||
image={image}
|
|
||||||
onClick={() => openImage(image)}
|
|
||||||
onContextMenu={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setContextMenu({ x: event.clientX, y: event.clientY, image });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{images.length > 0 && loadingImages ? (
|
{contextMenu ? (
|
||||||
<div className="flex justify-center py-8">
|
<ImageContextMenu
|
||||||
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
|
x={contextMenu.x}
|
||||||
</div>
|
y={contextMenu.y}
|
||||||
) : null}
|
image={contextMenu.image}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{contextMenu ? (
|
{/* Pinned to the bottom of the gallery viewport — outside the scroll
|
||||||
<ImageContextMenu
|
container so it stays put while the grid scrolls. */}
|
||||||
x={contextMenu.x}
|
<BulkActionBar />
|
||||||
y={contextMenu.y}
|
|
||||||
image={contextMenu.image}
|
|
||||||
onClose={() => setContextMenu(null)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pinned to the bottom of the gallery viewport — outside the scroll
|
|
||||||
container so it stays put while the grid scrolls. */}
|
|
||||||
<BulkActionBar />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-177
@@ -1,90 +1,15 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
|
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
|
||||||
import { ImageTile } from "./Gallery";
|
import { ImageTile } from "./gallery/ImageTile";
|
||||||
import { ImageContextMenu } from "./ImageContextMenu";
|
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 GAP = 6;
|
||||||
const HEADER_HEIGHT = 52;
|
const HEADER_HEIGHT = 52;
|
||||||
const SCRUBBER_WIDTH = 48;
|
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<string, ImageRecord[]>();
|
|
||||||
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<string, ScrubberYear>();
|
|
||||||
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() {
|
export function Timeline() {
|
||||||
const images = useGalleryStore((s) => s.images);
|
const images = useGalleryStore((s) => s.images);
|
||||||
@@ -130,26 +55,10 @@ export function Timeline() {
|
|||||||
[containerWidth, tileSize],
|
[containerWidth, tileSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Flatten the month groups into a single list of fixed-height rows — one
|
const { rows, rowToGroupIndex, groupFirstRow } = useMemo(
|
||||||
// header row per group, then one tile-row per `cols` images. This lets the
|
() => buildTimelineRows(groups, cols),
|
||||||
// virtualizer render only the on-screen rows, exactly like the Gallery.
|
[groups, cols],
|
||||||
// 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 estimateSize = useCallback(
|
const estimateSize = useCallback(
|
||||||
(index: number): number =>
|
(index: number): number =>
|
||||||
@@ -223,37 +132,9 @@ export function Timeline() {
|
|||||||
className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0"
|
className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0"
|
||||||
>
|
>
|
||||||
{images.length === 0 && loadingImages ? (
|
{images.length === 0 && loadingImages ? (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
|
<TimelineLoadingState />
|
||||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
|
|
||||||
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
|
|
||||||
<p className="mt-4 text-sm text-white/40 font-medium">Loading timeline</p>
|
|
||||||
<p className="text-xs text-white/20 mt-1">Fetching results</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : images.length === 0 ? (
|
) : images.length === 0 ? (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
|
<TimelineEmptyState imageLoadError={imageLoadError} />
|
||||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
|
|
||||||
<svg
|
|
||||||
className="h-12 w-12 mx-auto text-white/10 mb-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={0.75}
|
|
||||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p className="text-sm text-white/30 font-medium">
|
|
||||||
{imageLoadError ? "Could not load timeline" : "No media found"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-white/15 mt-1">
|
|
||||||
{imageLoadError ?? "Add a folder to see your timeline"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
@@ -348,51 +229,3 @@ export function Timeline() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="w-full flex flex-col items-center">
|
|
||||||
<Tooltip label={yearEntry.year} anchorToCursor>
|
|
||||||
<button
|
|
||||||
className={`w-full text-center py-0.5 text-[10px] font-semibold tracking-wide transition-colors ${
|
|
||||||
isYearActive ? "text-white/80" : "text-white/30 hover:text-white/55"
|
|
||||||
}`}
|
|
||||||
onClick={() => onScrollTo(yearEntry.firstGroupIndex)}
|
|
||||||
>
|
|
||||||
{yearEntry.year}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<div
|
|
||||||
className="grid gap-[3px] pb-1.5"
|
|
||||||
style={{ gridTemplateColumns: "repeat(3, 10px)" }}
|
|
||||||
>
|
|
||||||
{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 <span key={monthNum} className="h-[10px] w-[10px]" />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Tooltip key={monthNum} label={`${monthEntry.label} ${yearEntry.year}`} anchorToCursor>
|
|
||||||
<button
|
|
||||||
onClick={() => onScrollTo(monthEntry.groupIndex)}
|
|
||||||
className={`h-[10px] w-[10px] rounded-full transition-colors ${
|
|
||||||
isActive ? "bg-white/70" : "bg-white/15 hover:bg-white/40"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { ParsedSearch } from "../../store";
|
||||||
|
import { PhotoIcon } from "../icons";
|
||||||
|
|
||||||
|
export function GalleryLoadingState({
|
||||||
|
isSimilarResults,
|
||||||
|
parsedSearch,
|
||||||
|
}: {
|
||||||
|
isSimilarResults: boolean;
|
||||||
|
parsedSearch: ParsedSearch;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||||||
|
<div className="min-w-72 rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
|
||||||
|
<div className="mx-auto h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white/60" />
|
||||||
|
<p className="mt-4 text-sm font-medium text-white/40">
|
||||||
|
{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"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-white/20">
|
||||||
|
{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"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GalleryEmptyState({
|
||||||
|
imageLoadError,
|
||||||
|
isSimilarResults,
|
||||||
|
parsedSearch,
|
||||||
|
}: {
|
||||||
|
imageLoadError: string | null;
|
||||||
|
isSimilarResults: boolean;
|
||||||
|
parsedSearch: ParsedSearch;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
|
||||||
|
<PhotoIcon className="mx-auto mb-4 h-12 w-12 text-white/10" strokeWidth={0.75} />
|
||||||
|
<p className="text-sm font-medium text-white/30">
|
||||||
|
{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"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-white/15">
|
||||||
|
{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"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>) => 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 (
|
||||||
|
<div
|
||||||
|
className={`media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left transition-shadow focus:outline-none ${
|
||||||
|
selected ? "ring-2 ring-inset ring-blue-400/80" : ""
|
||||||
|
}`}
|
||||||
|
style={{ width: "100%", aspectRatio: "1 / 1" }}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 z-10 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
|
||||||
|
aria-label={`Open ${image.filename}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (selectionActive) toggleGallerySelected(image.id);
|
||||||
|
else onClick();
|
||||||
|
}}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={selected}
|
||||||
|
aria-label={selected ? "Deselect" : "Select"}
|
||||||
|
className="group/cb absolute left-0 top-0 z-20 h-11 w-11 cursor-pointer"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleGallerySelected(image.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full border transition-all duration-150 ${
|
||||||
|
selected
|
||||||
|
? "border-blue-400 bg-blue-500 text-white opacity-100"
|
||||||
|
: "border-white/70 bg-black/40 text-transparent opacity-0 backdrop-blur-sm group-hover/cb:opacity-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CheckIcon className="h-3 w-3" strokeWidth={3} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{src && !errored ? (
|
||||||
|
<>
|
||||||
|
{!loaded ? <div className="absolute inset-0 animate-pulse bg-white/[0.04]" /> : null}
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={image.filename}
|
||||||
|
className={`h-full w-full object-cover transition-all duration-300 ${
|
||||||
|
loaded ? "scale-100 opacity-100" : "scale-[1.02] opacity-0"
|
||||||
|
} group-hover:scale-[1.03]`}
|
||||||
|
loading="lazy"
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
onError={() => setErrored(true)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white/[0.03] text-white/20">
|
||||||
|
{image.media_kind === "video" ? (
|
||||||
|
<PlayIcon className="h-7 w-7" />
|
||||||
|
) : (
|
||||||
|
<PhotoIcon className="h-7 w-7" strokeWidth={1} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{image.media_kind === "video" ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="rounded-full bg-black/40 p-3 text-white opacity-50 backdrop-blur-sm transition-opacity duration-200 group-hover:opacity-90">
|
||||||
|
<PlayIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute right-2 top-2 flex flex-col items-end gap-1">
|
||||||
|
{image.embedding_status === "failed" ? (
|
||||||
|
<Tooltip label={image.embedding_error ?? "Embedding failed"} followCursor className="pointer-events-auto">
|
||||||
|
<div className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm">
|
||||||
|
<WarningIcon className="h-2.5 w-2.5 shrink-0" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{image.favorite ? (
|
||||||
|
<div className="rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
|
||||||
|
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{image.rating > 0 ? (
|
||||||
|
<div className="flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-1 text-amber-300 backdrop-blur-sm">
|
||||||
|
{Array.from({ length: image.rating }, (_, index) => (
|
||||||
|
<StarIcon key={index} className="h-2.5 w-2.5" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{image.media_kind === "video" && image.duration_ms ? (
|
||||||
|
<div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm">
|
||||||
|
{formatDuration(image.duration_ms)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
|
||||||
|
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 z-20 translate-y-1 p-2.5 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
|
||||||
|
<TruncatedFilename filename={image.filename} />
|
||||||
|
<div className="mt-1.5 flex items-center justify-between gap-2">
|
||||||
|
{image.rating > 0 ? (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{Array.from({ length: image.rating }, (_, index) => (
|
||||||
|
<StarIcon key={index} className="h-2.5 w-2.5 text-amber-300" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`pointer-events-auto relative z-20 rounded-md px-2 py-0.5 text-[10px] backdrop-blur-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80 ${
|
||||||
|
canFindSimilar
|
||||||
|
? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white"
|
||||||
|
: "cursor-not-allowed bg-white/5 text-white/30"
|
||||||
|
}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!canFindSimilar) return;
|
||||||
|
findSimilar(image.id, image.folder_id);
|
||||||
|
}}
|
||||||
|
disabled={!canFindSimilar}
|
||||||
|
>
|
||||||
|
Similar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { Tooltip } from "../Tooltip";
|
||||||
|
|
||||||
|
export function TruncatedFilename({ filename }: { filename: string }) {
|
||||||
|
const textRef = useRef<HTMLParagraphElement>(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 = (
|
||||||
|
<p ref={textRef} className="truncate text-[12px] font-medium leading-tight text-white">
|
||||||
|
{filename}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={filename} delay={500} block followCursor disabled={!isTruncated}>
|
||||||
|
{label}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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")}`;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex w-full flex-col items-center">
|
||||||
|
<Tooltip label={yearEntry.year} anchorToCursor>
|
||||||
|
<button
|
||||||
|
className={`w-full py-0.5 text-center text-[10px] font-semibold tracking-wide transition-colors ${
|
||||||
|
isYearActive ? "text-white/80" : "text-white/30 hover:text-white/55"
|
||||||
|
}`}
|
||||||
|
onClick={() => onScrollTo(yearEntry.firstGroupIndex)}
|
||||||
|
>
|
||||||
|
{yearEntry.year}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="grid gap-[3px] pb-1.5" style={{ gridTemplateColumns: "repeat(3, 10px)" }}>
|
||||||
|
{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 <span key={monthNum} className="h-[10px] w-[10px]" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip key={monthNum} label={`${monthEntry.label} ${yearEntry.year}`} anchorToCursor>
|
||||||
|
<button
|
||||||
|
onClick={() => onScrollTo(monthEntry.groupIndex)}
|
||||||
|
className={`h-[10px] w-[10px] rounded-full transition-colors ${
|
||||||
|
isActive ? "bg-white/70" : "bg-white/15 hover:bg-white/40"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export function TimelineLoadingState() {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||||||
|
<div className="min-w-72 rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
|
||||||
|
<div className="mx-auto h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white/60" />
|
||||||
|
<p className="mt-4 text-sm font-medium text-white/40">Loading timeline</p>
|
||||||
|
<p className="mt-1 text-xs text-white/20">Fetching results</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelineEmptyState({ imageLoadError }: { imageLoadError: string | null }) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
|
||||||
|
<svg
|
||||||
|
className="mx-auto mb-4 h-12 w-12 text-white/10"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={0.75}
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm font-medium text-white/30">
|
||||||
|
{imageLoadError ? "Could not load timeline" : "No media found"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-white/15">
|
||||||
|
{imageLoadError ?? "Add a folder to see your timeline"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, ImageRecord[]>();
|
||||||
|
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<string, ScrubberYear>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user