feat: add Phokus UI Lab
Add a dev-only Vite UI mode with Tauri API mocks, in-memory fixture scenarios, reusable media source handling, and documentation for browser-based visual testing.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { useGalleryStore } from "../store";
|
||||
import { getMockScenario } from "./mockScenarios";
|
||||
|
||||
export function applyMockScenario() {
|
||||
const scenario = getMockScenario();
|
||||
const store = useGalleryStore.getState();
|
||||
|
||||
if (scenario === "album") {
|
||||
const albumId = store.albums[0]?.id;
|
||||
if (albumId !== undefined) store.viewAlbum(albumId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scenario === "duplicates") {
|
||||
store.setView("duplicates");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import type { Album, Folder, ImageRecord, ImageTag, SortOrder } from "../store";
|
||||
import { compareImages, createMockDb, mockExif } from "./mockFixtures";
|
||||
import { getMockScenario } from "./mockScenarios";
|
||||
|
||||
const db = createMockDb(getMockScenario());
|
||||
let nextFolderId = 100;
|
||||
let nextAlbumId = 100;
|
||||
let nextTagId = 10_000;
|
||||
|
||||
type AnyPayload = Record<string, any> | undefined;
|
||||
|
||||
function params(payload: unknown): Record<string, any> {
|
||||
if (!payload || typeof payload !== "object") return {};
|
||||
const record = payload as Record<string, any>;
|
||||
return record.params && typeof record.params === "object" ? record.params : record;
|
||||
}
|
||||
|
||||
function page<T>(items: T[], offset = 0, limit = 200) {
|
||||
return {
|
||||
images: items.slice(offset, offset + limit),
|
||||
total: items.length,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
function filterImages(input: ImageRecord[], payload: unknown): ImageRecord[] {
|
||||
const p = params(payload);
|
||||
const query = String(p.search ?? p.query ?? "").trim().toLowerCase();
|
||||
return input.filter((image) => {
|
||||
if (p.folder_id !== undefined && p.folder_id !== null && image.folder_id !== p.folder_id) return false;
|
||||
if (p.media_kind && image.media_kind !== p.media_kind) return false;
|
||||
if (p.favorites_only && !image.favorite) return false;
|
||||
if (p.rating_min !== undefined && p.rating_min !== null && image.rating < p.rating_min) return false;
|
||||
if (p.embedding_failed_only && image.embedding_status !== "failed") return false;
|
||||
if (p.tagging_failed_only && !image.ai_tagger_error) return false;
|
||||
if (query && !image.filename.toLowerCase().includes(query)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function getImages(payload: unknown) {
|
||||
const p = params(payload);
|
||||
const sort = (p.sort ?? "date_desc") as SortOrder;
|
||||
return page(
|
||||
filterImages(db.images, payload).sort(compareImages(sort)),
|
||||
Number(p.offset ?? 0),
|
||||
Number(p.limit ?? 200),
|
||||
);
|
||||
}
|
||||
|
||||
function searchTags(payload: unknown) {
|
||||
const p = params(payload);
|
||||
const query = String(p.query ?? "").trim().toLowerCase();
|
||||
const matchingIds = new Set(
|
||||
Object.entries(db.tagsByImageId)
|
||||
.filter(([, tags]) => tags.some((tag) => tag.tag.toLowerCase().includes(query)))
|
||||
.map(([imageId]) => Number(imageId)),
|
||||
);
|
||||
const images = filterImages(db.images, payload)
|
||||
.filter((image) => matchingIds.has(image.id))
|
||||
.sort(compareImages((p.sort ?? "date_desc") as SortOrder));
|
||||
return page(images, Number(p.offset ?? 0), Number(p.limit ?? 200));
|
||||
}
|
||||
|
||||
function semanticSearch(payload: unknown): ImageRecord[] {
|
||||
const p = params(payload);
|
||||
const query = String(p.query ?? "").toLowerCase();
|
||||
const scored = filterImages(db.images, payload)
|
||||
.map((image, index) => ({
|
||||
image,
|
||||
score:
|
||||
(image.generated_caption?.toLowerCase().includes(query) ? 0 : 20) +
|
||||
(image.filename.toLowerCase().includes(query) ? 0 : 10) +
|
||||
(index % 9),
|
||||
}))
|
||||
.sort((left, right) => left.score - right.score)
|
||||
.slice(0, Number(p.limit ?? 200))
|
||||
.map((entry) => entry.image);
|
||||
return scored.length ? scored : filterImages(db.images, payload).slice(0, Number(p.limit ?? 200));
|
||||
}
|
||||
|
||||
function albumImages(payload: unknown) {
|
||||
const p = params(payload);
|
||||
const albumId = Number(p.album_id);
|
||||
const ids = new Set(db.albumImageIds[albumId] ?? []);
|
||||
const sort = (p.sort ?? "date_desc") as SortOrder;
|
||||
return page(
|
||||
db.images.filter((image) => ids.has(image.id)).sort(compareImages(sort)),
|
||||
Number(p.offset ?? 0),
|
||||
Number(p.limit ?? 200),
|
||||
);
|
||||
}
|
||||
|
||||
function similarImages(payload: unknown) {
|
||||
const p = params(payload);
|
||||
const source = db.images.find((image) => image.id === p.image_id);
|
||||
let images = db.images.filter((image) => image.id !== p.image_id && image.embedding_status === "ready");
|
||||
if (p.album_id !== undefined && p.album_id !== null) {
|
||||
const ids = new Set(db.albumImageIds[Number(p.album_id)] ?? []);
|
||||
images = images.filter((image) => ids.has(image.id));
|
||||
} else if (p.folder_id !== undefined && p.folder_id !== null) {
|
||||
images = images.filter((image) => image.folder_id === p.folder_id);
|
||||
}
|
||||
const ordered = images.sort((left, right) => {
|
||||
const leftScore = Math.abs(left.rating - (source?.rating ?? 0)) + (left.folder_id === source?.folder_id ? 0 : 2);
|
||||
const rightScore = Math.abs(right.rating - (source?.rating ?? 0)) + (right.folder_id === source?.folder_id ? 0 : 2);
|
||||
return leftScore - rightScore;
|
||||
});
|
||||
const offset = Number(p.offset ?? 0);
|
||||
const limit = Number(p.limit ?? 200);
|
||||
return {
|
||||
images: ordered.slice(offset, offset + limit),
|
||||
offset,
|
||||
limit,
|
||||
has_more: offset + limit < ordered.length,
|
||||
};
|
||||
}
|
||||
|
||||
function updateImages(ids: number[], updates: { favorite?: boolean | null; rating?: number | null }) {
|
||||
const changed: ImageRecord[] = [];
|
||||
for (const image of db.images) {
|
||||
if (!ids.includes(image.id)) continue;
|
||||
if (updates.favorite !== null && updates.favorite !== undefined) image.favorite = updates.favorite;
|
||||
if (updates.rating !== null && updates.rating !== undefined) image.rating = updates.rating;
|
||||
changed.push({ ...image });
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function refreshAlbumCounts() {
|
||||
for (const album of db.albums) {
|
||||
const ids = db.albumImageIds[album.id] ?? [];
|
||||
album.image_count = ids.length;
|
||||
album.cover_image_id = ids[0] ?? null;
|
||||
album.cover_thumbnail_path = db.images.find((image) => image.id === ids[0])?.thumbnail_path ?? null;
|
||||
album.updated_at = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
function createAlbum(name: string): Album {
|
||||
const album: Album = {
|
||||
id: nextAlbumId++,
|
||||
name,
|
||||
cover_image_id: null,
|
||||
cover_thumbnail_path: null,
|
||||
image_count: 0,
|
||||
sort_order: db.albums.length + 1,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
db.albums.push(album);
|
||||
db.albumImageIds[album.id] = [];
|
||||
return album;
|
||||
}
|
||||
|
||||
function addFolder(path: string): Folder {
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
const name = parts[parts.length - 1] ?? "New Folder";
|
||||
const folder: Folder = {
|
||||
id: nextFolderId++,
|
||||
path,
|
||||
name,
|
||||
image_count: 0,
|
||||
indexed_at: new Date().toISOString(),
|
||||
scan_error: null,
|
||||
sort_order: db.folders.length + 1,
|
||||
};
|
||||
db.folders.push(folder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
function safeFallback(cmd: string, payload: AnyPayload): unknown {
|
||||
if (cmd.includes("app|version")) return "0.1.1-ui";
|
||||
if (cmd.includes("path|resolve_directory")) return "C:\\Users\\phokus\\AppData\\Roaming\\Phokus";
|
||||
if (cmd.includes("path|join")) {
|
||||
const paths = payload?.paths ?? payload?.args ?? [];
|
||||
return Array.isArray(paths) ? paths.join("\\") : "C:\\Users\\phokus\\AppData\\Roaming\\Phokus";
|
||||
}
|
||||
if (cmd.includes("notification|is_permission_granted")) return false;
|
||||
if (cmd.includes("notification|request_permission")) return "denied";
|
||||
if (cmd.includes("dialog|open")) return null;
|
||||
if (cmd.includes("window|is_maximized")) return false;
|
||||
if (cmd.includes("window|minimize")) return null;
|
||||
if (cmd.includes("window|toggle_maximize")) return null;
|
||||
if (cmd.includes("window|close")) return null;
|
||||
if (cmd.includes("opener|reveal_item_in_dir")) return null;
|
||||
if (cmd.includes("opener|open")) return null;
|
||||
if (cmd.includes("process|relaunch")) return null;
|
||||
console.warn("[Phokus UI Lab] Unmocked command:", cmd, payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function handleMockCommand(cmd: string, payload?: unknown): Promise<unknown> {
|
||||
const p = params(payload);
|
||||
|
||||
switch (cmd) {
|
||||
case "get_folders":
|
||||
return db.folders;
|
||||
case "add_folder":
|
||||
addFolder(String(p.path ?? "C:\\Users\\phokus\\Pictures\\NewFolder"));
|
||||
return null;
|
||||
case "add_folders":
|
||||
return (p.paths ?? []).map((path: string) => ({ status: "added", data: addFolder(path) }));
|
||||
case "remove_folder":
|
||||
db.folders = db.folders.filter((folder) => folder.id !== p.folderId);
|
||||
return null;
|
||||
case "rename_folder": {
|
||||
const folder = db.folders.find((entry) => entry.id === p.folderId);
|
||||
if (folder) folder.name = String(p.newName ?? folder.name);
|
||||
return null;
|
||||
}
|
||||
case "update_folder_path": {
|
||||
const folder = db.folders.find((entry) => entry.id === p.folderId);
|
||||
if (folder) folder.path = String(p.newPath ?? folder.path);
|
||||
return null;
|
||||
}
|
||||
case "reindex_folder":
|
||||
case "reorder_folders":
|
||||
return null;
|
||||
case "list_directories":
|
||||
return {
|
||||
current: p.path ?? null,
|
||||
parent: p.path ? "C:\\Users\\phokus" : null,
|
||||
entries: db.folders.map((folder) => ({ name: folder.name, path: folder.path, has_children: false })),
|
||||
};
|
||||
case "get_background_job_progress":
|
||||
return db.backgroundJobs;
|
||||
case "get_images":
|
||||
return getImages(payload);
|
||||
case "semantic_search_images":
|
||||
return semanticSearch(payload);
|
||||
case "search_images_by_tag":
|
||||
return searchTags(payload);
|
||||
case "get_images_by_ids":
|
||||
return (p.image_ids ?? []).map((id: number) => db.images.find((image) => image.id === id)).filter(Boolean);
|
||||
case "find_similar_images":
|
||||
case "find_similar_by_region":
|
||||
return similarImages(payload);
|
||||
case "get_tag_cloud":
|
||||
return db.scenario === "empty" ? [] : db.tagCloud;
|
||||
case "get_explore_tags":
|
||||
return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 48));
|
||||
case "suggest_image_tags":
|
||||
return ["select", "warm light"];
|
||||
case "rename_tag":
|
||||
case "delete_tag":
|
||||
return cmd === "delete_tag" ? 6 : null;
|
||||
case "get_image_tags":
|
||||
return db.tagsByImageId[p.image_id] ?? [];
|
||||
case "add_user_tag": {
|
||||
const tag: ImageTag = { id: nextTagId++, image_id: p.image_id, tag: p.tag, source: "user", ai_model: null, confidence: null, created_at: new Date().toISOString() };
|
||||
db.tagsByImageId[p.image_id] = [...(db.tagsByImageId[p.image_id] ?? []), tag];
|
||||
return tag;
|
||||
}
|
||||
case "remove_tag":
|
||||
for (const [imageId, imageTags] of Object.entries(db.tagsByImageId)) {
|
||||
db.tagsByImageId[Number(imageId)] = imageTags.filter((tag) => tag.id !== p.tag_id);
|
||||
}
|
||||
return null;
|
||||
case "get_image_exif":
|
||||
return mockExif(Number(p.image_id));
|
||||
case "update_image_details":
|
||||
return updateImages([Number(p.image_id)], { favorite: p.favorite, rating: p.rating })[0];
|
||||
case "bulk_update_details":
|
||||
return updateImages(p.image_ids ?? [], { favorite: p.favorite, rating: p.rating });
|
||||
case "bulk_add_tags":
|
||||
case "bulk_remove_tag":
|
||||
return null;
|
||||
case "delete_images_from_disk":
|
||||
return p.image_ids ?? [];
|
||||
case "list_albums":
|
||||
refreshAlbumCounts();
|
||||
return db.albums;
|
||||
case "create_album":
|
||||
return createAlbum(String(p.name ?? "Untitled Album"));
|
||||
case "rename_album": {
|
||||
const album = db.albums.find((entry) => entry.id === p.album_id);
|
||||
if (album) album.name = String(p.new_name ?? album.name);
|
||||
return null;
|
||||
}
|
||||
case "delete_album":
|
||||
db.albums = db.albums.filter((album) => album.id !== p.album_id);
|
||||
delete db.albumImageIds[p.album_id];
|
||||
return null;
|
||||
case "delete_albums":
|
||||
db.albums = db.albums.filter((album) => !(p.album_ids ?? []).includes(album.id));
|
||||
for (const id of p.album_ids ?? []) delete db.albumImageIds[id];
|
||||
return null;
|
||||
case "reorder_albums":
|
||||
return null;
|
||||
case "add_images_to_album": {
|
||||
const existing = new Set(db.albumImageIds[p.album_id] ?? []);
|
||||
for (const id of p.image_ids ?? []) existing.add(id);
|
||||
db.albumImageIds[p.album_id] = [...existing];
|
||||
refreshAlbumCounts();
|
||||
return p.image_ids?.length ?? 0;
|
||||
}
|
||||
case "remove_images_from_album":
|
||||
db.albumImageIds[p.album_id] = (db.albumImageIds[p.album_id] ?? []).filter((id) => !(p.image_ids ?? []).includes(id));
|
||||
refreshAlbumCounts();
|
||||
return null;
|
||||
case "get_album_images":
|
||||
return albumImages(payload);
|
||||
case "load_duplicate_scan_cache":
|
||||
return db.duplicateScannedAt ? { groups: db.duplicateGroups, scanned_at: db.duplicateScannedAt } : null;
|
||||
case "find_duplicates":
|
||||
await emit("duplicate_scan_progress", { phase: "hashing", processed: Math.floor(db.images.length * 0.6), total: db.images.length, skipped: db.scenario === "errors" ? 3 : 0 });
|
||||
db.duplicateScannedAt = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
groups: db.duplicateGroups,
|
||||
scanned_files: db.images.length,
|
||||
candidate_files: db.duplicateGroups.flatMap((group) => group.images).length,
|
||||
skipped_files: db.scenario === "errors" ? 3 : 0,
|
||||
};
|
||||
case "invalidate_duplicate_scan_cache":
|
||||
return null;
|
||||
case "get_caption_model_status":
|
||||
return { model_id: "Salesforce/blip-image-captioning-base", model_name: "BLIP Base", local_dir: "mock://models/caption", ready: true, missing_files: [] };
|
||||
case "get_caption_acceleration":
|
||||
case "set_caption_acceleration":
|
||||
return p.acceleration ?? "auto";
|
||||
case "get_caption_detail":
|
||||
case "set_caption_detail":
|
||||
return p.detail ?? "paragraph";
|
||||
case "prepare_caption_model":
|
||||
case "delete_caption_model":
|
||||
return { model_id: "Salesforce/blip-image-captioning-base", model_name: "BLIP Base", local_dir: "mock://models/caption", ready: true, missing_files: [] };
|
||||
case "probe_caption_runtime":
|
||||
return { ready: true, acceleration: "auto", detail: "paragraph", tokenizer_vocab_size: 30522, sessions: [] };
|
||||
case "probe_caption_image":
|
||||
return { input_shape: [1, 3, 384, 384], output_shape: [1, 768], output_values: 768, acceleration: "auto" };
|
||||
case "generate_caption_for_image": {
|
||||
const image = db.images.find((entry) => entry.id === p.image_id);
|
||||
if (image) image.generated_caption = `Generated in UI Lab for ${image.filename}.`;
|
||||
return image;
|
||||
}
|
||||
case "queue_caption_jobs":
|
||||
case "clear_caption_jobs":
|
||||
case "reset_generated_captions":
|
||||
case "queue_tagging_jobs":
|
||||
case "clear_tagging_jobs":
|
||||
return 12;
|
||||
case "get_tagger_model_status":
|
||||
return { model_id: "SmilingWolf/wd-vit-tagger-v3", model_name: "WD ViT Tagger", local_dir: "mock://models/tagger", ready: true, missing_files: [] };
|
||||
case "get_tagger_acceleration":
|
||||
case "set_tagger_acceleration":
|
||||
return p.acceleration ?? "auto";
|
||||
case "get_tagger_threshold":
|
||||
case "set_tagger_threshold":
|
||||
return p.threshold ?? 0.35;
|
||||
case "get_tagger_batch_size":
|
||||
case "set_tagger_batch_size":
|
||||
return p.batch_size ?? 8;
|
||||
case "probe_tagger_runtime":
|
||||
return { ready: true, acceleration: "auto", session: { file: "model.onnx", inputs: ["input"], outputs: ["tags"] } };
|
||||
case "get_tagging_queue_scope":
|
||||
return "all";
|
||||
case "get_tagging_queue_folder_ids":
|
||||
case "get_muted_folder_ids":
|
||||
return [];
|
||||
case "set_tagging_queue_scope":
|
||||
case "set_tagging_queue_folder_ids":
|
||||
case "set_muted_folder_ids":
|
||||
case "set_notifications_paused":
|
||||
case "set_worker_paused":
|
||||
return null;
|
||||
case "get_notifications_paused":
|
||||
case "get_onboarding_completed":
|
||||
return db.scenario !== "empty";
|
||||
case "set_onboarding_completed":
|
||||
case "set_last_seen_version":
|
||||
return null;
|
||||
case "get_worker_states":
|
||||
return (p.folderIds ?? []).map((folder_id: number) => ({ folder_id, thumbnail_paused: false, metadata_paused: false, embedding_paused: false, tagging_paused: false }));
|
||||
case "get_build_variant":
|
||||
return "cpu";
|
||||
case "get_last_seen_version":
|
||||
return "0.1.1-ui";
|
||||
case "get_ffmpeg_status":
|
||||
return { installed: true, downloading: false, failed: false };
|
||||
case "retry_ffmpeg_download":
|
||||
case "open_app_data_folder":
|
||||
case "open_map_location":
|
||||
case "open_changelog_url":
|
||||
return null;
|
||||
case "get_database_info":
|
||||
return { size_mb: 14.2, reclaimable_mb: 1.8 };
|
||||
case "vacuum_database":
|
||||
return { before_mb: 14.2, after_mb: 12.4, freed_mb: 1.8 };
|
||||
case "rebuild_semantic_index":
|
||||
return db.images.length;
|
||||
case "get_orphaned_thumbnails_info":
|
||||
return { count: 9, size_mb: 3.4 };
|
||||
case "cleanup_orphaned_thumbnails":
|
||||
return { deleted_count: 9, freed_mb: 3.4 };
|
||||
case "retry_failed_embeddings":
|
||||
return null;
|
||||
default:
|
||||
return safeFallback(cmd, payload as AnyPayload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import type {
|
||||
Album,
|
||||
AiRating,
|
||||
DuplicateGroup,
|
||||
ExploreTagEntry,
|
||||
Folder,
|
||||
FolderJobProgress,
|
||||
ImageExif,
|
||||
ImageRecord,
|
||||
ImageTag,
|
||||
SortOrder,
|
||||
TagCloudEntry,
|
||||
} from "../store";
|
||||
import type { MockScenario } from "./mockScenarios";
|
||||
import alpineLake from "../assets/onboarding/alpinelake.webp";
|
||||
import architecture from "../assets/onboarding/architecture.webp";
|
||||
import balloon from "../assets/onboarding/balloon.webp";
|
||||
import beach from "../assets/onboarding/beach.webp";
|
||||
import cat from "../assets/onboarding/cat.webp";
|
||||
import cityNight from "../assets/onboarding/citynight.webp";
|
||||
import cozy from "../assets/onboarding/cozy.webp";
|
||||
import dunes from "../assets/onboarding/dunes.webp";
|
||||
import flower from "../assets/onboarding/flower.webp";
|
||||
import forest from "../assets/onboarding/forest.webp";
|
||||
import landscape1 from "../assets/onboarding/landscape1.webp";
|
||||
import landscape2 from "../assets/onboarding/landscape2.webp";
|
||||
import sunset from "../assets/onboarding/sunset.webp";
|
||||
import sunsetCoast from "../assets/onboarding/sunsetcoast.webp";
|
||||
import sunsetLake from "../assets/onboarding/sunsetlake.webp";
|
||||
|
||||
export interface MockDb {
|
||||
scenario: MockScenario;
|
||||
folders: Folder[];
|
||||
images: ImageRecord[];
|
||||
albums: Album[];
|
||||
albumImageIds: Record<number, number[]>;
|
||||
tagsByImageId: Record<number, ImageTag[]>;
|
||||
tagCloud: TagCloudEntry[];
|
||||
exploreTags: ExploreTagEntry[];
|
||||
backgroundJobs: FolderJobProgress[];
|
||||
duplicateGroups: DuplicateGroup[];
|
||||
duplicateScannedAt: number | null;
|
||||
}
|
||||
|
||||
const folderNames = ["Camera Roll", "Client Selects", "Video Archive"];
|
||||
const mediaNames = [
|
||||
"alpine-lake",
|
||||
"city-after-rain",
|
||||
"coastal-walk",
|
||||
"studio-portrait",
|
||||
"market-neon",
|
||||
"forest-floor",
|
||||
"product-macro",
|
||||
"golden-hour",
|
||||
"night-drive",
|
||||
"desk-flatlay",
|
||||
"museum-hall",
|
||||
"wildflower",
|
||||
];
|
||||
const tags = [
|
||||
"landscape",
|
||||
"portrait",
|
||||
"street",
|
||||
"travel",
|
||||
"night",
|
||||
"product",
|
||||
"editorial",
|
||||
"architecture",
|
||||
"nature",
|
||||
"warm light",
|
||||
"blue hour",
|
||||
"select",
|
||||
];
|
||||
const aiRatings: AiRating[] = ["general", "sensitive", "questionable"];
|
||||
const fixtureAssets = [
|
||||
alpineLake,
|
||||
architecture,
|
||||
balloon,
|
||||
beach,
|
||||
cat,
|
||||
cityNight,
|
||||
cozy,
|
||||
dunes,
|
||||
flower,
|
||||
forest,
|
||||
landscape1,
|
||||
landscape2,
|
||||
sunset,
|
||||
sunsetCoast,
|
||||
sunsetLake,
|
||||
];
|
||||
|
||||
function daysAgo(days: number): string {
|
||||
return new Date(Date.now() - days * 86_400_000).toISOString();
|
||||
}
|
||||
|
||||
function mockPath(folder: number, name: string, index: number, ext: string): string {
|
||||
return `mock://full/${folder}-${name}-${index}.${ext}`;
|
||||
}
|
||||
|
||||
function mockThumb(index: number): string {
|
||||
return fixtureAssets[index % fixtureAssets.length];
|
||||
}
|
||||
|
||||
function makeImage(index: number, folderId: number, scenario: MockScenario): ImageRecord {
|
||||
const name = mediaNames[index % mediaNames.length];
|
||||
const isVideo = index % 11 === 6;
|
||||
const failedEmbedding = scenario === "errors" && index % 9 === 0;
|
||||
const failedTagging = scenario === "errors" && index % 7 === 0;
|
||||
const ext = isVideo ? "mp4" : "jpg";
|
||||
return {
|
||||
id: index + 1,
|
||||
folder_id: folderId,
|
||||
path: isVideo ? mockPath(folderId, name, index + 1, ext) : mockThumb(index),
|
||||
filename: `${name}-${String(index + 1).padStart(3, "0")}.${ext}`,
|
||||
thumbnail_path: scenario === "errors" && index % 10 === 3 ? "mock://missing-thumb.svg" : mockThumb(index),
|
||||
width: isVideo ? 3840 : 3000 + (index % 5) * 180,
|
||||
height: isVideo ? 2160 : 2000 + (index % 4) * 160,
|
||||
file_size: 480_000 + index * 73_000,
|
||||
created_at: daysAgo(index + 3),
|
||||
modified_at: daysAgo(index + 1),
|
||||
taken_at: daysAgo(index + 10),
|
||||
mime_type: isVideo ? "video/mp4" : "image/jpeg",
|
||||
media_kind: isVideo ? "video" : "image",
|
||||
duration_ms: isVideo ? 42_000 + index * 1300 : null,
|
||||
video_codec: isVideo ? "h264" : null,
|
||||
audio_codec: isVideo ? "aac" : null,
|
||||
metadata_updated_at: daysAgo(index),
|
||||
metadata_error: scenario === "errors" && index % 8 === 2 ? "EXIF parse failed in UI fixture" : null,
|
||||
favorite: index % 5 === 0,
|
||||
rating: index % 6,
|
||||
embedding_status: failedEmbedding ? "failed" : index % 13 === 0 ? "processing" : "ready",
|
||||
embedding_model: failedEmbedding ? null : "clip-vit-b-32",
|
||||
embedding_updated_at: failedEmbedding ? null : daysAgo(index),
|
||||
embedding_error: failedEmbedding ? "Fixture embedding failure" : null,
|
||||
generated_caption: `Fixture caption for ${name.replace(/-/g, " ")}.`,
|
||||
caption_model: "blip-base",
|
||||
caption_updated_at: daysAgo(index),
|
||||
caption_error: null,
|
||||
ai_rating: aiRatings[index % aiRatings.length],
|
||||
ai_tagger_model: failedTagging ? null : "wd-vit-tagger-v3",
|
||||
ai_tagged_at: failedTagging ? null : daysAgo(index),
|
||||
ai_tagger_error: failedTagging ? "Fixture tagger failure" : null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFolders(scenario: MockScenario): Folder[] {
|
||||
if (scenario === "empty") return [];
|
||||
return folderNames.map((name, index) => ({
|
||||
id: index + 1,
|
||||
path: `C:\\Users\\phokus\\Pictures\\${name.replace(/ /g, "")}`,
|
||||
name,
|
||||
image_count: 0,
|
||||
indexed_at: daysAgo(index + 1),
|
||||
scan_error: scenario === "errors" && index === 1 ? "Folder moved or permission denied" : null,
|
||||
sort_order: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] {
|
||||
if (scenario === "empty") return [];
|
||||
const count = scenario === "huge" ? 720 : scenario === "errors" ? 54 : 72;
|
||||
const images = Array.from({ length: count }, (_, index) => makeImage(index, folders[index % folders.length].id, scenario));
|
||||
for (const folder of folders) {
|
||||
folder.image_count = images.filter((image) => image.folder_id === folder.id).length;
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
function makeTags(images: ImageRecord[]): Record<number, ImageTag[]> {
|
||||
return Object.fromEntries(
|
||||
images.map((image, index) => [
|
||||
image.id,
|
||||
[0, 1, 2].map((offset) => ({
|
||||
id: image.id * 10 + offset,
|
||||
image_id: image.id,
|
||||
tag: tags[(index + offset) % tags.length],
|
||||
source: offset === 0 ? "user" : "ai",
|
||||
ai_model: offset === 0 ? null : "wd-vit-tagger-v3",
|
||||
confidence: offset === 0 ? null : 0.72 + offset * 0.07,
|
||||
created_at: daysAgo(index + offset),
|
||||
})),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function makeAlbums(images: ImageRecord[], scenario: MockScenario): { albums: Album[]; albumImageIds: Record<number, number[]> } {
|
||||
if (scenario === "empty") return { albums: [], albumImageIds: {} };
|
||||
const selects = images.filter((image) => image.rating >= 4).slice(0, 36).map((image) => image.id);
|
||||
const motion = images.filter((image) => image.media_kind === "video").map((image) => image.id);
|
||||
const favorites = images.filter((image) => image.favorite).slice(0, 40).map((image) => image.id);
|
||||
const albumImageIds = { 1: selects, 2: motion, 3: favorites };
|
||||
const albums: Album[] = [
|
||||
{ id: 1, name: "Portfolio Shortlist", cover_image_id: selects[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === selects[0])?.thumbnail_path ?? null, image_count: selects.length, sort_order: 1, created_at: daysAgo(40), updated_at: daysAgo(2) },
|
||||
{ id: 2, name: "Motion Tests", cover_image_id: motion[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === motion[0])?.thumbnail_path ?? null, image_count: motion.length, sort_order: 2, created_at: daysAgo(38), updated_at: daysAgo(3) },
|
||||
{ id: 3, name: "Favorites", cover_image_id: favorites[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === favorites[0])?.thumbnail_path ?? null, image_count: favorites.length, sort_order: 3, created_at: daysAgo(20), updated_at: daysAgo(1) },
|
||||
];
|
||||
return { albums, albumImageIds };
|
||||
}
|
||||
|
||||
function makeProgress(folders: Folder[], scenario: MockScenario): FolderJobProgress[] {
|
||||
return folders.map((folder, index) => ({
|
||||
folder_id: folder.id,
|
||||
thumbnail_pending: scenario === "busy" ? 18 - index * 3 : 0,
|
||||
metadata_pending: scenario === "busy" ? 12 - index * 2 : 0,
|
||||
embedding_pending: scenario === "busy" ? 36 - index * 4 : scenario === "errors" ? 4 : 0,
|
||||
embedding_ready: Math.max(0, folder.image_count - (scenario === "busy" ? 20 : 2)),
|
||||
embedding_failed: scenario === "errors" ? 3 + index : 0,
|
||||
caption_pending: scenario === "busy" ? 24 - index * 2 : 0,
|
||||
caption_ready: Math.max(0, Math.floor(folder.image_count * 0.5)),
|
||||
caption_failed: scenario === "errors" ? 2 : 0,
|
||||
tagging_pending: scenario === "busy" ? 30 - index * 5 : 0,
|
||||
tagging_ready: Math.max(0, Math.floor(folder.image_count * 0.65)),
|
||||
tagging_failed: scenario === "errors" ? 4 : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function makeTagCloud(images: ImageRecord[]): TagCloudEntry[] {
|
||||
return tags.slice(0, 10).map((_, index) => {
|
||||
const group = images.filter((image) => image.id % (index + 2) === 0).slice(0, 28);
|
||||
const representative = group[0] ?? images[index];
|
||||
return {
|
||||
count: Math.max(group.length, 1),
|
||||
representative_image_id: representative?.id ?? index + 1,
|
||||
thumbnail_path: representative?.thumbnail_path ?? null,
|
||||
image_ids: group.length ? group.map((image) => image.id) : [representative?.id ?? 1],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function makeExploreTags(images: ImageRecord[]): ExploreTagEntry[] {
|
||||
return tags.map((tag, index) => {
|
||||
const representative = images[index % Math.max(images.length, 1)];
|
||||
return {
|
||||
tag,
|
||||
count: Math.max(3, Math.round(images.length / (index + 3))),
|
||||
representative_image_id: representative?.id ?? index + 1,
|
||||
thumbnail_path: representative?.thumbnail_path ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function makeDuplicateGroups(images: ImageRecord[], scenario: MockScenario): DuplicateGroup[] {
|
||||
if (scenario === "empty") return [];
|
||||
const candidates = images.filter((image) => image.media_kind === "image");
|
||||
const groups = [0, 1, 2, 3].map((groupIndex) => ({
|
||||
file_hash: `fixture-hash-${groupIndex}`,
|
||||
file_size: candidates[groupIndex]?.file_size ?? 1_200_000,
|
||||
images: candidates.slice(groupIndex * 4, groupIndex * 4 + (groupIndex === 1 ? 3 : 2)),
|
||||
})).filter((group) => group.images.length > 1);
|
||||
return scenario === "duplicates" ? groups.concat(groups.map((group, index) => ({
|
||||
...group,
|
||||
file_hash: `${group.file_hash}-extra`,
|
||||
images: candidates.slice(20 + index * 3, 23 + index * 3),
|
||||
})).filter((group) => group.images.length > 1)) : groups;
|
||||
}
|
||||
|
||||
export function createMockDb(scenario: MockScenario): MockDb {
|
||||
const folders = makeFolders(scenario);
|
||||
const images = makeImages(scenario, folders);
|
||||
const { albums, albumImageIds } = makeAlbums(images, scenario);
|
||||
return {
|
||||
scenario,
|
||||
folders,
|
||||
images,
|
||||
albums,
|
||||
albumImageIds,
|
||||
tagsByImageId: makeTags(images),
|
||||
tagCloud: makeTagCloud(images),
|
||||
exploreTags: makeExploreTags(images),
|
||||
backgroundJobs: makeProgress(folders, scenario),
|
||||
duplicateGroups: makeDuplicateGroups(images, scenario),
|
||||
duplicateScannedAt: scenario === "duplicates" ? Math.floor(Date.now() / 1000) - 420 : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function compareImages(sort: SortOrder): (left: ImageRecord, right: ImageRecord) => number {
|
||||
return (left, right) => {
|
||||
switch (sort) {
|
||||
case "name_asc":
|
||||
return left.filename.localeCompare(right.filename);
|
||||
case "name_desc":
|
||||
return right.filename.localeCompare(left.filename);
|
||||
case "size_asc":
|
||||
return left.file_size - right.file_size;
|
||||
case "size_desc":
|
||||
return right.file_size - left.file_size;
|
||||
case "rating_asc":
|
||||
return left.rating - right.rating;
|
||||
case "rating_desc":
|
||||
return right.rating - left.rating;
|
||||
case "duration_asc":
|
||||
return (left.duration_ms ?? 0) - (right.duration_ms ?? 0);
|
||||
case "duration_desc":
|
||||
return (right.duration_ms ?? 0) - (left.duration_ms ?? 0);
|
||||
case "date_asc":
|
||||
case "taken_asc":
|
||||
return Date.parse(left.taken_at ?? left.created_at ?? "") - Date.parse(right.taken_at ?? right.created_at ?? "");
|
||||
case "date_desc":
|
||||
case "taken_desc":
|
||||
default:
|
||||
return Date.parse(right.taken_at ?? right.created_at ?? "") - Date.parse(left.taken_at ?? left.created_at ?? "");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function mockExif(imageId: number): ImageExif {
|
||||
return {
|
||||
make: "Phokus Fixture Co.",
|
||||
model: `MockCam ${100 + imageId}`,
|
||||
lens: "35mm f/1.8",
|
||||
iso: String(100 + (imageId % 8) * 100),
|
||||
f_number: "f/4",
|
||||
exposure_time: "1/250",
|
||||
focal_length: "35mm",
|
||||
datetime_original: daysAgo(imageId),
|
||||
gps_lat: imageId % 2 === 0 ? 51.5072 : null,
|
||||
gps_lon: imageId % 2 === 0 ? -0.1276 : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type MockScenario = "rich" | "empty" | "busy" | "duplicates" | "album" | "errors" | "huge";
|
||||
|
||||
const SCENARIOS = new Set<MockScenario>([
|
||||
"rich",
|
||||
"empty",
|
||||
"busy",
|
||||
"duplicates",
|
||||
"album",
|
||||
"errors",
|
||||
"huge",
|
||||
]);
|
||||
|
||||
export function getMockScenario(): MockScenario {
|
||||
if (typeof window === "undefined") return "rich";
|
||||
const value = new URLSearchParams(window.location.search).get("scenario");
|
||||
return value && SCENARIOS.has(value as MockScenario) ? (value as MockScenario) : "rich";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { mockConvertFileSrc, mockIPC, mockWindows } from "@tauri-apps/api/mocks";
|
||||
import { handleMockCommand } from "./mockBackend";
|
||||
|
||||
mockWindows("main");
|
||||
mockConvertFileSrc("windows");
|
||||
|
||||
mockIPC((cmd, payload) => handleMockCommand(cmd, payload), {
|
||||
shouldMockEvents: true,
|
||||
});
|
||||
|
||||
console.info("[Phokus UI Lab] Mock Tauri backend installed.");
|
||||
Reference in New Issue
Block a user