-
+
{exploreMode === "visual"
? "No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar."
: "No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview."}
diff --git a/src/components/ThemedDropdown.tsx b/src/components/ThemedDropdown.tsx
new file mode 100644
index 0000000..db8a833
--- /dev/null
+++ b/src/components/ThemedDropdown.tsx
@@ -0,0 +1,106 @@
+import { useEffect, useRef, useState } from "react";
+
+export interface DropdownOption {
+ value: string;
+ label: string;
+}
+
+export function ThemedDropdown({
+ value,
+ options,
+ onChange,
+ ariaLabel,
+ compact = false,
+ align = "right",
+}: {
+ value: string;
+ options: DropdownOption[];
+ onChange: (value: string) => void;
+ ariaLabel: string;
+ compact?: boolean;
+ align?: "left" | "right";
+}) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ const current = options.find((option) => option.value === value) ?? options[0];
+
+ useEffect(() => {
+ const handlePointerDown = (event: PointerEvent) => {
+ if (!ref.current?.contains(event.target as Node)) setOpen(false);
+ };
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setOpen(false);
+ };
+ window.addEventListener("pointerdown", handlePointerDown);
+ window.addEventListener("keydown", handleKeyDown);
+ return () => {
+ window.removeEventListener("pointerdown", handlePointerDown);
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, []);
+
+ return (
+
+
+
+ {open ? (
+
+ {options.map((option) => {
+ const selected = option.value === value;
+ return (
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx
index edd4f6b..a2de2eb 100644
--- a/src/components/Timeline.tsx
+++ b/src/components/Timeline.tsx
@@ -5,6 +5,8 @@ import { ContextMenu, ImageTile } from "./Gallery";
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;
@@ -12,6 +14,23 @@ interface TimelineGroup {
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("-");
@@ -44,6 +63,27 @@ function groupImages(images: ImageRecord[]): TimelineGroup[] {
.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);
const loadMoreImages = useGalleryStore((s) => s.loadMoreImages);
@@ -55,13 +95,15 @@ export function Timeline() {
const parentRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(0);
+ const [activeGroupIndex, setActiveGroupIndex] = useState(0);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
image: ImageRecord;
} | null>(null);
- // Measure container width before first paint to avoid a single-column flash.
+ // parentRef is the scroll container. Its clientWidth already excludes the
+ // scrubber because they are flex siblings, so no further adjustment is needed.
useLayoutEffect(() => {
const el = parentRef.current;
if (!el) return;
@@ -74,35 +116,59 @@ export function Timeline() {
}, []);
const tileSize = tileSizeForZoom(zoomPreset);
+ const groups = useMemo(() => groupImages(images), [images]);
+ const scrubberYears = useMemo(() => buildScrubberYears(groups), [groups]);
+ // Show as soon as there's more than one month to jump between — not gated on
+ // a full year. With taken_asc sort the loaded set is oldest-first, so this
+ // reflects whatever range is currently loaded.
+ const showScrubber = groups.length > 1;
+
const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize],
);
- const groups = useMemo(() => groupImages(images), [images]);
+ // 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]);
- // estimateSize must be exact so virtualizer positions groups correctly.
- // Each group height = header + rowCount * (tileSize + GAP) where the last row's
- // GAP acts as spacing between this group and the next header.
const estimateSize = useCallback(
- (index: number): number => {
- const group = groups[index];
- if (!group) return HEADER_HEIGHT;
- const rowCount = Math.ceil(group.images.length / cols);
- return HEADER_HEIGHT + rowCount * (tileSize + GAP);
- },
- [groups, cols, tileSize],
+ (index: number): number =>
+ rows[index]?.type === "header" ? HEADER_HEIGHT : tileSize + GAP,
+ [rows, tileSize],
);
const virtualizer = useVirtualizer({
- count: groups.length,
+ count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize,
- overscan: 2,
+ overscan: 6,
+ paddingStart: GAP,
});
- // Re-measure all items when cols changes so virtualizer positions stay accurate
- // after a window resize (react-virtual v3 doesn't invalidate cached sizes on its own).
+ // Refs so the scroll handler can read the latest mappings without re-binding.
+ const rowToGroupIndexRef = useRef(rowToGroupIndex);
+ rowToGroupIndexRef.current = rowToGroupIndex;
+ const groupFirstRowRef = useRef(groupFirstRow);
+ groupFirstRowRef.current = groupFirstRow;
+
useEffect(() => {
virtualizer.measure();
}, [cols, virtualizer]);
@@ -110,12 +176,25 @@ export function Timeline() {
const handleScroll = useCallback(() => {
const el = parentRef.current;
if (!el) return;
- if (el.scrollTop < 24) return;
- const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600;
+
+ // Active month = the group owning the first row still visible at the top.
+ const scrollTop = el.scrollTop;
+ const items = virtualizer.getVirtualItems();
+ let activeRow = items.length > 0 ? items[0].index : 0;
+ for (const item of items) {
+ if (item.start + item.size > scrollTop + HEADER_HEIGHT / 2) {
+ activeRow = item.index;
+ break;
+ }
+ }
+ setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0);
+
+ if (scrollTop < 24) return;
+ const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages();
}
- }, [images.length, loadMoreImages, loadingImages, totalImages]);
+ }, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages]);
useEffect(() => {
const el = parentRef.current;
@@ -140,105 +219,135 @@ export function Timeline() {
};
}, []);
- return (
-
- {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) => {
- const group = groups[virtualItem.index];
- if (!group) return null;
- return (
-
- {/* Group header */}
-
-
- {group.label}
-
-
- {group.images.length}
-
-
-
+ const scrollToGroup = useCallback(
+ (groupIndex: number) => {
+ const row = groupFirstRowRef.current[groupIndex] ?? 0;
+ virtualizer.scrollToIndex(row, { align: "start" });
+ },
+ [virtualizer],
+ );
- {/* Image grid — paddingBottom:GAP gives the gap below the last row,
- matching the row-to-row gap and making estimateSize exact. */}
+ return (
+ // Outer flex-row: fills remaining height in
's flex-col, then
+ // splits horizontally between the scroll area and the scrubber.
+
+ {/* Scroll container — flex-1 takes all width except the scrubber */}
+
+ {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) => {
+ const row = rows[virtualItem.index];
+ if (!row) return null;
+ return (
- {group.images.map((image) => (
-
openImage(image)}
- onContextMenu={(event) => {
- event.preventDefault();
- setContextMenu({ x: event.clientX, y: event.clientY, image });
+ {row.type === "header" ? (
+
+
+ {row.group.label}
+
+
+ {row.group.images.length}
+
+
+
+ ) : (
+
- ))}
+ >
+ {row.images.map((image) => (
+ openImage(image)}
+ onContextMenu={(event) => {
+ event.preventDefault();
+ setContextMenu({ x: event.clientX, y: event.clientY, image });
+ }}
+ />
+ ))}
+
+ )}
-
- );
- })}
-
- )}
+ );
+ })}
+
+ )}
- {images.length > 0 && loadingImages ? (
-
-
+ {images.length > 0 && loadingImages ? (
+
+ ) : null}
+
+
+ {/* Scrubber — flex sibling so it stays visible while the left panel scrolls */}
+ {showScrubber ? (
+
+ {scrubberYears.map((yearEntry) => (
+
+ ))}
) : null}
@@ -253,3 +362,51 @@ 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/TitleBar.tsx b/src/components/TitleBar.tsx
index a08499a..cfd7e5a 100644
--- a/src/components/TitleBar.tsx
+++ b/src/components/TitleBar.tsx
@@ -24,7 +24,7 @@ function RestoreIcon() {
return (
);
}
@@ -88,7 +88,7 @@ export function TitleBar() {
aria-label={`Update available — click to update to Phokus v${updateVersion}`}
className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12"
>
-
+
{/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */}
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
index c276410..56e021e 100644
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -55,8 +55,8 @@ function SortDropdown({
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
open
- ? "border-white/15 bg-white/8 text-white"
- : "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200"
+ ? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white"
+ : "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
>
{current?.label ?? "Sort"}
@@ -68,14 +68,14 @@ function SortDropdown({
{open ? (
-
+
{options.map((option) => (