Initial commit — Tauri image gallery with sqlite-vec

Sets up Tauri v2 + React frontend with SQLite metadata store,
sqlite-vec for CLIP embedding infrastructure, and r2d2 connection
pool replacing the single Arc<Mutex<Connection>>. Indexer now uses
rayon for parallel file metadata collection.
This commit is contained in:
2026-04-05 15:59:48 +01:00
commit c5e9c83ac9
51 changed files with 10540 additions and 0 deletions
+361
View File
@@ -0,0 +1,361 @@
import { create } from "zustand";
import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { appDataDir, join } from "@tauri-apps/api/path";
export interface Folder {
id: number;
path: string;
name: string;
image_count: number;
indexed_at: string | null;
}
export type MediaKind = "image" | "video";
export type MediaFilter = "all" | MediaKind;
export type ZoomPreset = "compact" | "comfortable" | "detail";
export interface ImageRecord {
id: number;
folder_id: number;
path: string;
filename: string;
thumbnail_path: string | null;
width: number | null;
height: number | null;
file_size: number;
created_at: string | null;
modified_at: string | null;
mime_type: string;
media_kind: MediaKind;
favorite: boolean;
rating: number;
embedding_status: string;
embedding_model: string | null;
embedding_updated_at: string | null;
embedding_error: string | null;
}
export interface IndexProgress {
folder_id: number;
total: number;
indexed: number;
current_file: string;
done: boolean;
}
export interface IndexedImagesBatch {
folder_id: number;
images: ImageRecord[];
}
export type SortOrder =
| "date_desc"
| "date_asc"
| "name_asc"
| "name_desc"
| "size_desc"
| "size_asc";
interface GalleryState {
folders: Folder[];
selectedFolderId: number | null;
images: ImageRecord[];
totalImages: number;
loadedCount: number;
loadingImages: boolean;
search: string;
sort: SortOrder;
mediaFilter: MediaFilter;
favoritesOnly: boolean;
zoomPreset: ZoomPreset;
selectedImage: ImageRecord | null;
indexingProgress: Record<number, IndexProgress>;
cacheDir: string;
loadFolders: () => Promise<void>;
addFolder: (path: string) => Promise<void>;
removeFolder: (folderId: number) => Promise<void>;
reindexFolder: (folderId: number) => Promise<void>;
selectFolder: (folderId: number | null) => void;
loadImages: (reset?: boolean) => Promise<void>;
loadMoreImages: () => Promise<void>;
setSearch: (search: string) => void;
setSort: (sort: SortOrder) => void;
setMediaFilter: (filter: MediaFilter) => void;
setFavoritesOnly: (favoritesOnly: boolean) => void;
setZoomPreset: (zoomPreset: ZoomPreset) => void;
openImage: (image: ImageRecord) => void;
closeImage: () => void;
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
setCacheDir: (dir: string) => void;
subscribeToProgress: () => Promise<UnlistenFn>;
}
const PAGE_SIZE = 200;
function matchesSearch(image: ImageRecord, search: string): boolean {
if (!search) return true;
return image.filename.toLowerCase().includes(search.toLowerCase());
}
function matchesFilters(
image: ImageRecord,
selectedFolderId: number | null,
mediaFilter: MediaFilter,
favoritesOnly: boolean,
search: string,
): boolean {
const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId;
const matchesMedia = mediaFilter === "all" || image.media_kind === mediaFilter;
const matchesFavorite = !favoritesOnly || image.favorite;
return matchesFolder && matchesMedia && matchesFavorite && matchesSearch(image, search);
}
function compareNullableNumber(a: number | null, b: number | null): number {
return (a ?? 0) - (b ?? 0);
}
function compareNullableDate(a: string | null, b: string | null): number {
return (a ? Date.parse(a) : 0) - (b ? Date.parse(b) : 0);
}
function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number {
switch (sort) {
case "name_asc":
return a.filename.localeCompare(b.filename);
case "name_desc":
return b.filename.localeCompare(a.filename);
case "date_asc":
return compareNullableDate(a.modified_at, b.modified_at);
case "date_desc":
return compareNullableDate(b.modified_at, a.modified_at);
case "size_asc":
return compareNullableNumber(a.file_size, b.file_size);
case "size_desc":
return compareNullableNumber(b.file_size, a.file_size);
default:
return compareNullableDate(b.modified_at, a.modified_at);
}
}
function mergeImages(currentImages: ImageRecord[], newImages: ImageRecord[], sort: SortOrder): ImageRecord[] {
const merged = new Map<string, ImageRecord>();
for (const image of currentImages) {
merged.set(image.path, image);
}
for (const image of newImages) {
merged.set(image.path, image);
}
return Array.from(merged.values()).sort((a, b) => compareImages(a, b, sort));
}
function replaceImage(images: ImageRecord[], updatedImage: ImageRecord, sort: SortOrder): ImageRecord[] {
return mergeImages(images, [updatedImage], sort);
}
export function tileSizeForZoom(zoomPreset: ZoomPreset): number {
switch (zoomPreset) {
case "compact":
return 160;
case "detail":
return 280;
default:
return 220;
}
}
export const useGalleryStore = create<GalleryState>((set, get) => ({
folders: [],
selectedFolderId: null,
images: [],
totalImages: 0,
loadedCount: 0,
loadingImages: false,
search: "",
sort: "date_desc",
mediaFilter: "all",
favoritesOnly: false,
zoomPreset: "comfortable",
selectedImage: null,
indexingProgress: {},
cacheDir: "",
setCacheDir: (cacheDir) => set({ cacheDir }),
loadFolders: async () => {
const folders = await invoke<Folder[]>("get_folders");
set({ folders });
},
addFolder: async (path) => {
const { cacheDir, loadFolders } = get();
await invoke("add_folder", { path, cacheDir });
await loadFolders();
},
removeFolder: async (folderId) => {
await invoke("remove_folder", { folderId });
const { selectedFolderId, loadFolders, loadImages } = get();
await loadFolders();
if (selectedFolderId === folderId) {
set({ selectedFolderId: null });
await loadImages(true);
}
},
reindexFolder: async (folderId) => {
const { cacheDir, loadFolders } = get();
await invoke("reindex_folder", { folderId, cacheDir });
await loadFolders();
},
selectFolder: (folderId) => {
set({ selectedFolderId: folderId, images: [], loadedCount: 0 });
void get().loadImages(true);
},
loadImages: async (reset = false) => {
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly } = get();
set({ loadingImages: true });
try {
const offset = reset ? 0 : loadedCount;
const result = await invoke<{
images: ImageRecord[];
total: number;
offset: number;
limit: number;
}>("get_images", {
params: {
folder_id: selectedFolderId,
search: search || null,
media_kind: mediaFilter === "all" ? null : mediaFilter,
favorites_only: favoritesOnly,
sort,
offset,
limit: PAGE_SIZE,
},
});
set((state) => ({
images: reset ? result.images : [...state.images, ...result.images],
totalImages: result.total,
loadedCount: reset ? result.images.length : state.loadedCount + result.images.length,
loadingImages: false,
}));
} catch (error) {
console.error("Failed to load media:", error);
set({ loadingImages: false });
}
},
loadMoreImages: async () => {
const { loadedCount, totalImages, loadingImages } = get();
if (loadingImages || loadedCount >= totalImages) return;
await get().loadImages(false);
},
setSearch: (search) => {
set({ search, images: [], loadedCount: 0 });
void get().loadImages(true);
},
setSort: (sort) => {
set({ sort, images: [], loadedCount: 0 });
void get().loadImages(true);
},
setMediaFilter: (mediaFilter) => {
set({ mediaFilter, images: [], loadedCount: 0 });
void get().loadImages(true);
},
setFavoritesOnly: (favoritesOnly) => {
set({ favoritesOnly, images: [], loadedCount: 0 });
void get().loadImages(true);
},
setZoomPreset: (zoomPreset) => set({ zoomPreset }),
openImage: (image) => set({ selectedImage: image }),
closeImage: () => set({ selectedImage: null }),
updateImageDetails: async (imageId, updates) => {
const updatedImage = await invoke<ImageRecord>("update_image_details", {
params: {
image_id: imageId,
favorite: updates.favorite ?? null,
rating: updates.rating ?? null,
},
});
set((state) => ({
images: replaceImage(state.images, updatedImage, state.sort),
selectedImage: state.selectedImage?.id === updatedImage.id ? updatedImage : state.selectedImage,
}));
},
subscribeToProgress: async () => {
const unlistenProgress = await listen<IndexProgress>("index-progress", (event) => {
const progress = event.payload;
set((state) => ({
indexingProgress: {
...state.indexingProgress,
[progress.folder_id]: progress,
},
}));
if (progress.done) {
void get().loadFolders();
setTimeout(() => {
set((state) => {
const next = { ...state.indexingProgress };
delete next[progress.folder_id];
return { indexingProgress: next };
});
}, 2000);
}
});
const unlistenImages = await listen<IndexedImagesBatch>("indexed-images", (event) => {
const batch = event.payload;
set((state) => {
const visibleImages = batch.images.filter((image) =>
matchesFilters(
image,
state.selectedFolderId,
state.mediaFilter,
state.favoritesOnly,
state.search,
),
);
if (visibleImages.length === 0) {
return state;
}
const images = mergeImages(state.images, visibleImages, state.sort);
return {
images,
loadedCount: images.length,
totalImages: Math.max(state.totalImages, images.length),
};
});
});
return () => {
unlistenProgress();
unlistenImages();
};
},
}));
appDataDir().then(async (dir) => {
useGalleryStore.getState().setCacheDir(await join(dir, "thumbnails"));
});