From e90075b23301fc6c46c07379c00999d50aa36175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kenneth=20Wu=C3=9Fmann?= Date: Tue, 5 Nov 2024 21:58:29 +0100 Subject: [PATCH] Various improvements (#8) --- .../components/caption-list-dropdown.tsx | 2 +- .../common/double-confirmation-dialog.tsx | 63 +++++ .../advanced-settings-content.tsx | 25 +- .../provider/caption-editor-provider.tsx | 3 +- .../provider/dataset-directory-provider.tsx | 19 +- .../provider/image-navigation-provider.tsx | 3 +- src/hooks/use-image-importer.tsx | 78 +++--- src/hooks/use-images.tsx | 3 +- src/lib/database/database-provider.tsx | 110 +++++--- src/lib/database/database.ts | 13 +- src/lib/types.ts | 17 ++ src/pages/setup/access-denied-alert.tsx | 3 + src/pages/setup/loading-dataset-view.tsx | 70 +++++ src/pages/setup/page.tsx | 255 +++--------------- src/pages/setup/select-action-view.tsx | 97 +++++++ src/pages/setup/select-directory-view.tsx | 71 +++++ src/pages/setup/unsupported-browser-alert.tsx | 26 ++ 17 files changed, 538 insertions(+), 320 deletions(-) create mode 100644 src/components/common/double-confirmation-dialog.tsx create mode 100644 src/pages/setup/access-denied-alert.tsx create mode 100644 src/pages/setup/loading-dataset-view.tsx create mode 100644 src/pages/setup/select-action-view.tsx create mode 100644 src/pages/setup/select-directory-view.tsx create mode 100644 src/pages/setup/unsupported-browser-alert.tsx diff --git a/src/components/caption/components/caption-list-dropdown.tsx b/src/components/caption/components/caption-list-dropdown.tsx index 8b6373e..c361392 100644 --- a/src/components/caption/components/caption-list-dropdown.tsx +++ b/src/components/caption/components/caption-list-dropdown.tsx @@ -9,7 +9,7 @@ export const CaptionListDropdown = () => { const { copy, paste, hasContent } = useCaptionClipboard(); return ( - + diff --git a/src/components/common/double-confirmation-dialog.tsx b/src/components/common/double-confirmation-dialog.tsx new file mode 100644 index 0000000..69fb7c0 --- /dev/null +++ b/src/components/common/double-confirmation-dialog.tsx @@ -0,0 +1,63 @@ +import { useState, cloneElement, ReactElement } from "react"; +import { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle, DialogDescription, Button, DialogFooter } from "../ui"; + +type DoubleConfirmationDialogProps = { + title?: string; + message?: string; + children: ReactElement; +}; + +export const DoubleConfirmationDialog = ({ + title = "Are you sure?", + message = "This action cannot be undone.", + children, +}: DoubleConfirmationDialogProps) => { + const [isOpen, setOpen] = useState(false); + const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null); + + const handleClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (children.props.onClick) { + setOnConfirm(() => children.props.onClick); + } + + setOpen(true); + }; + + const handleConfirm = () => { + if (onConfirm) onConfirm(); + setOpen(false); + }; + + const handleCancel = () => { + setOpen(false); + }; + + return ( + + + {cloneElement(children, { onClick: handleClick })} + + + + {title} + + {message} + + + +
+ + +
+
+
+
+ ); +}; diff --git a/src/components/settings/content/advanced-settings/advanced-settings-content.tsx b/src/components/settings/content/advanced-settings/advanced-settings-content.tsx index 2727ba3..ca6c7f2 100644 --- a/src/components/settings/content/advanced-settings/advanced-settings-content.tsx +++ b/src/components/settings/content/advanced-settings/advanced-settings-content.tsx @@ -9,6 +9,7 @@ import { useDatasetDirectory } from "@/hooks/provider/dataset-directory-provider import { useState } from "react"; import { useToast } from "@/hooks/use-toast"; import { deleteAllIndexedDBs } from "@/lib/utils"; +import { DoubleConfirmationDialog } from "@/components/common/double-confirmation-dialog"; const AdvancedSettingsContent = () => { const { deleteAllTextFiles } = useDatasetDirectory(); @@ -60,9 +61,11 @@ const AdvancedSettingsContent = () => { - + + + @@ -75,9 +78,11 @@ const AdvancedSettingsContent = () => { - + + + @@ -90,9 +95,11 @@ const AdvancedSettingsContent = () => { - + + + diff --git a/src/hooks/provider/caption-editor-provider.tsx b/src/hooks/provider/caption-editor-provider.tsx index 2f81e45..b1d3d76 100644 --- a/src/hooks/provider/caption-editor-provider.tsx +++ b/src/hooks/provider/caption-editor-provider.tsx @@ -12,8 +12,8 @@ import { usePreventClose } from "./prevent-close-provider"; import { uuid } from "@/lib/utils"; import { usePrevious } from "@uidotdev/usehooks" import { useShortcut } from "../use-shortcut"; -import { database } from "@/lib/database/database"; import { useCaptionPreview } from "../use-caption-preview"; +import { useDatabase } from "@/lib/database/database-provider"; interface CaptionEditorContextType { parts: CaptionPart[]; @@ -44,6 +44,7 @@ interface CaptionEditorProviderProps { export const CaptionEditorProvider: React.FC = ({ children, }) => { + const { database } = useDatabase() const { currentImage } = useImageNavigation(); const [isDirty, setDirty] = useState(false); const [parts, setParts] = useState([]); diff --git a/src/hooks/provider/dataset-directory-provider.tsx b/src/hooks/provider/dataset-directory-provider.tsx index 273b496..fde99ed 100644 --- a/src/hooks/provider/dataset-directory-provider.tsx +++ b/src/hooks/provider/dataset-directory-provider.tsx @@ -10,7 +10,7 @@ import { ImageFile, TextFile } from "@/lib/types"; type DatasetDirectoryContextType = { supported: boolean; - openDirectoryPicker: () => Promise; + openDirectoryPicker: () => Promise; isDirectorySelected: boolean; imageFiles: ImageFile[]; textFiles: TextFile[]; @@ -25,6 +25,7 @@ type DatasetDirectoryContextType = { writeCaption: (caption: string, image: ImageFile) => Promise; deleteAllTextFiles: () => Promise; directoryHandle: FileSystemDirectoryHandle | null; + loadDirectory: (handle: FileSystemDirectoryHandle) => Promise; }; const DatasetDirectoryContext = createContext< @@ -66,7 +67,7 @@ export const DatasetDirectoryProvider = ({ setAccessDenied(false); }, []); - const loadDirectory = useCallback( + const loadDirectory = async (handle: FileSystemDirectoryHandle) => { setDirectoryLoaded(false); setImageFiles([]); @@ -138,9 +139,9 @@ export const DatasetDirectoryProvider = ({ setFailedImageFiles(failedImagesCount); setFailedTextFiles(orphanTextFilesCount); setDirectoryLoaded(true); - }, - [] - ); + + return finalImageFiles + } const openDirectoryPicker = useCallback(async () => { if (!supported || !window.showDirectoryPicker) { @@ -152,13 +153,14 @@ export const DatasetDirectoryProvider = ({ const dirHandle = await window.showDirectoryPicker({ mode: "readwrite" }); setDirectoryHandle(dirHandle); setIsDirectorySelected(true); - await loadDirectory(dirHandle); + return dirHandle; } catch (err) { console.error("Error opening directory:", err); resetState(); setAccessDenied(true); } - }, [supported, loadDirectory, resetState]); + return null + }, [supported, resetState]); const writeTextFile = useCallback( async (filePath: string, content: string) => { @@ -313,7 +315,8 @@ export const DatasetDirectoryProvider = ({ loadImage, writeCaption, directoryHandle, - deleteAllTextFiles + deleteAllTextFiles, + loadDirectory }; return ( diff --git a/src/hooks/provider/image-navigation-provider.tsx b/src/hooks/provider/image-navigation-provider.tsx index bbc7570..13d59a4 100644 --- a/src/hooks/provider/image-navigation-provider.tsx +++ b/src/hooks/provider/image-navigation-provider.tsx @@ -3,7 +3,7 @@ import { useImages } from "../use-images"; import { useShortcut } from "../use-shortcut"; import { ImageEntity } from "@/lib/database/image-entity"; import { useLiveQuery } from "dexie-react-hooks"; -import { database } from "@/lib/database/database"; +import { useDatabase } from "@/lib/database/database-provider"; interface ImageNavigationContextType { currentImage?: ImageEntity; @@ -17,6 +17,7 @@ interface ImageNavigationContextType { const ImageNavigationContext = createContext(undefined); export const ImageNavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { database } = useDatabase() const { images } = useImages(); const [currentImageId, setCurrentImageId] = useState(); const [hasNextImage, setHasNextImage] = useState(false); diff --git a/src/hooks/use-image-importer.tsx b/src/hooks/use-image-importer.tsx index 860ba39..b4bf16c 100644 --- a/src/hooks/use-image-importer.tsx +++ b/src/hooks/use-image-importer.tsx @@ -4,64 +4,64 @@ import { useAtom } from "jotai/react"; import { useDatasetDirectory } from "./provider/dataset-directory-provider"; import { getFilenameWithoutExtension } from "@/lib/utils"; import { useState } from "react"; -import { useImages } from "./use-images"; import { ImageEntity } from "@/lib/database/image-entity"; -import { database } from "@/lib/database/database"; import { useDatabase } from "@/lib/database/database-provider"; +import { Database } from "@/lib/database/database"; export const useImageImporter = () => { const { setAutoBackupEnabled } = useDatabase() const [separator] = useAtom(settings.caption.separator); const { directoryHandle } = useDatasetDirectory(); - const { allImages } = useImages() const [imported, setImported] = useState(false) - const importImages = (imageFiles: ImageFile[]) => { + const importImages = async ( + database: Database, + existingImages: ImageEntity[], + imageFiles: ImageFile[] + ) => { if (!directoryHandle || imported) { return } - (async () => { - const imageDocs = await Promise.all(imageFiles.map(async (image): Promise => { - const existingImage = allImages.find(existingImage => existingImage.filename === image.name) - let caption: Caption | null = null - try { - const captionFileHandle = await directoryHandle.getFileHandle(getFilenameWithoutExtension(image.name) + ".txt") - const file = await captionFileHandle.getFile() - const captionText = await file.text() + const imageDocs = await Promise.all(imageFiles.map(async (image): Promise => { + const existingImage = existingImages.find(existingImage => existingImage.filename === image.name) + let caption: Caption | null = null + try { + const captionFileHandle = await directoryHandle.getFileHandle(getFilenameWithoutExtension(image.name) + ".txt") + const file = await captionFileHandle.getFile() + const captionText = await file.text() - if (captionText && captionText.trim().length > 0) { - caption = { - parts: (captionText.split(separator).map(part => part.trim()) || []).filter(part => part.length > 0).map((text, index) => ({ id: index.toString(), text, index })), - preview: captionText - } + if (captionText && captionText.trim().length > 0) { + caption = { + parts: (captionText.split(separator).map(part => part.trim()) || []).filter(part => part.length > 0).map((text, index) => ({ id: index.toString(), text, index })), + preview: captionText } - } catch { - // No caption file } + } catch { + // No caption file + } - if (caption) { - return { - id: image.name, - filename: image.name, - caption: caption.preview, - captionParts: caption.parts - } - } else if (existingImage) { - return existingImage - } else { - return { - id: image.name, - filename: image.name, - } + if (caption) { + return { + id: image.name, + filename: image.name, + caption: caption.preview, + captionParts: caption.parts + } + } else if (existingImage) { + return null + } else { + return { + id: image.name, + filename: image.name, } - })) + } + })) - await database.images.bulkPut(imageDocs.filter(doc => doc !== null)) + await database.images.bulkPut(imageDocs.filter(doc => doc !== null)) - setImported(true) - setAutoBackupEnabled(true) - console.log("Imported images") - })() + setImported(true) + setAutoBackupEnabled(true) + console.log("Imported images") } return { importImages, imported } diff --git a/src/hooks/use-images.tsx b/src/hooks/use-images.tsx index 7e37c13..b46a221 100644 --- a/src/hooks/use-images.tsx +++ b/src/hooks/use-images.tsx @@ -2,12 +2,13 @@ import { settings } from "@/lib/settings"; import { useAtom } from "jotai/react"; import { useAction } from "./use-action"; import { isImageDone } from "@/lib/database/image-entity"; -import { database } from "@/lib/database/database"; import { useLiveQuery } from "dexie-react-hooks"; +import { useDatabase } from "@/lib/database/database-provider"; export const useImages = () => { const action = useAction() const [hideDone] = useAtom(settings.appearance.hideDoneImages) + const { database } = useDatabase() const allImages = useLiveQuery(() => database.images.toArray()) const doneImages = action && allImages ? allImages.filter(image => isImageDone(image, action)) : [] diff --git a/src/lib/database/database-provider.tsx b/src/lib/database/database-provider.tsx index 8378812..98e93e5 100644 --- a/src/lib/database/database-provider.tsx +++ b/src/lib/database/database-provider.tsx @@ -7,17 +7,21 @@ import { useState, } from "react"; import { useDatasetDirectory } from "@/hooks/provider/dataset-directory-provider"; -import { tryJSONParse } from "../utils"; -import { database, Database, wipeDatabase } from "./database"; -import { importDB, exportDB, } from "dexie-export-import"; +import { tryJSONParse, uuid } from "../utils"; +import { Database } from "./database"; +import { exportDB, importDB, } from "dexie-export-import"; +import Dexie from "dexie"; +import { DexieBackup, dexieBackupSchema } from "../types"; type DatabaseContextType = { + database: Database; deleteDatabaseBackup: () => Promise; isLoading: boolean; isSaving: boolean; isInitialized: boolean; isAutoBackupEnabled: boolean; setAutoBackupEnabled: (enabled: boolean) => void; + initializeDatabase: (handle: FileSystemDirectoryHandle) => Promise; }; const DatabaseContext = createContext( @@ -32,15 +36,19 @@ export const useDatabase = () => { }; export const DatabaseProvider = ({ children }: { children: ReactNode }) => { - const { directoryHandle, writeTextFile, isDirectoryLoaded } = + const { directoryHandle, writeTextFile } = useDatasetDirectory(); const [isLoading, setIsLoading] = useState(false); const [isInitialized, setInitialized] = useState(false); const [isSaving, setSaving] = useState(false); const [isAutoBackupEnabled, setAutoBackupEnabled] = useState(false); + const [database, setDatabase] = useState(null); const saveDatabaseBackup = useCallback( async (localDb?: Database) => { + if (!database) { + return; + } console.log("Starting database backup") const db = localDb ?? database; if (!db || !directoryHandle) { @@ -50,6 +58,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { const blob = await exportDB(db, { noTransaction: true, + prettyJson: true, }); const str = await blob.text(); await writeTextFile( @@ -58,17 +67,10 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { ); setSaving(false); }, - [directoryHandle, writeTextFile] + [directoryHandle, writeTextFile, database] ); - const initializeDatabase = useCallback(async () => { - if (!directoryHandle || !isDirectoryLoaded || isInitialized) { - return; - } - setIsLoading(true); - - await wipeDatabase(); - + const loadDexieBackup = async (directoryHandle: FileSystemDirectoryHandle): Promise<{ backup: DexieBackup, file: Blob } | null> => { try { const captionNowDir = await directoryHandle.getDirectoryHandle( ".caption-now", @@ -85,27 +87,77 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { ); const backupFile = await backupFileHandle.getFile(); const backupFileText = tryJSONParse(await backupFile.text()); - const isDexieExport = backupFileText?.formatName === "dexie" + const dexieBackupParse = dexieBackupSchema.safeParse(backupFileText); - if (isDexieExport) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - console.log("Importing existing database"); - await importDB(backupFile); + if (dexieBackupParse.success) { + return { + backup: dexieBackupParse.data, + file: backupFile, + }; } else { - console.error("Unsupported database backup format", backupFileText); + console.error("Unsupported database backup format", backupFileText, dexieBackupParse.error); } } catch (e) { console.error("Failed to import existing database", e); } - - setInitialized(true); console.log("Database initialized with collections"); } catch (error) { console.error("Error initializing database:", error); - } finally { - setIsLoading(false); } - }, [directoryHandle, isDirectoryLoaded, isInitialized]); + return null + } + + const deleteExistingDatabase = async (name: string) => { + const databases = await indexedDB.databases(); + const db = databases.find((db) => db.name === name); + if (db) { + console.log("Deleting existing database", db.name); + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(db.name!); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + } + + const importExistingDatabase = async (directoryHandle: FileSystemDirectoryHandle): Promise => { + const db: Database | null = null; + const backupFile = await loadDexieBackup(directoryHandle); + + if (backupFile) { + await deleteExistingDatabase(backupFile.backup.data.databaseName); + await new Promise((resolve) => { + setTimeout(resolve, 1000) + }) + return await importDB(backupFile.file) as Database + } + return db + } + + const createNewDatabase = () => { + const database = new Dexie(`caption-now-${uuid()}`) as Database; + database.version(1).stores({ + images: "id, filename, tags, captionParts, caption", + }); + + return database + } + + const initializeDatabase = async (directoryHandle: FileSystemDirectoryHandle) => { + setIsLoading(true); + setInitialized(false); + + let db = await importExistingDatabase(directoryHandle); + + if (!db) { + db = createNewDatabase(); + } + + setDatabase(db); + setInitialized(true); + setIsLoading(false); + return db + }; const deleteDatabaseBackup = async () => { if (!directoryHandle) { @@ -115,11 +167,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { } useEffect(() => { - initializeDatabase(); - }, [initializeDatabase]); - - useEffect(() => { - if (!isAutoBackupEnabled) { + if (!isAutoBackupEnabled || !database) { return } @@ -136,7 +184,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { database.images.hook("updating").unsubscribe(listener) database.images.hook("deleting").unsubscribe(listener) } - }, [isAutoBackupEnabled, saveDatabaseBackup]) + }, [isAutoBackupEnabled, saveDatabaseBackup, database]) const value = { isLoading, @@ -145,6 +193,8 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { deleteDatabaseBackup, isAutoBackupEnabled, setAutoBackupEnabled, + initializeDatabase, + database: database! }; return ( diff --git a/src/lib/database/database.ts b/src/lib/database/database.ts index dcc3dc7..2aa0f54 100644 --- a/src/lib/database/database.ts +++ b/src/lib/database/database.ts @@ -1,17 +1,6 @@ import Dexie from "dexie"; import { ImageEntity } from "./image-entity"; -export const database = new Dexie("caption-now") as Dexie & { +export type Database = Dexie & { images: Dexie.Table; }; -database.version(1).stores({ - images: "id, filename, tags, captionParts, caption", -}); - -export type Database = typeof database; - -export const wipeDatabase = async () => { - database.close(); - await database.delete(); - await database.open(); -}; diff --git a/src/lib/types.ts b/src/lib/types.ts index cbd560d..b3a3833 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export type DirectoryFile = { name: string; type: string; @@ -23,3 +25,18 @@ export type Caption = { parts: CaptionPart[]; preview?: string; }; + +export const dexieBackupSchema = z + .object({ + formatName: z.literal("dexie"), + formatVersion: z.literal(1), + data: z + .object({ + databaseName: z.string(), + databaseVersion: z.number(), + }) + .passthrough(), + }) + .passthrough(); + +export type DexieBackup = z.infer; diff --git a/src/pages/setup/access-denied-alert.tsx b/src/pages/setup/access-denied-alert.tsx new file mode 100644 index 0000000..88fc63d --- /dev/null +++ b/src/pages/setup/access-denied-alert.tsx @@ -0,0 +1,3 @@ +export const AccessDeniedAlert = () => { + return <> +} \ No newline at end of file diff --git a/src/pages/setup/loading-dataset-view.tsx b/src/pages/setup/loading-dataset-view.tsx new file mode 100644 index 0000000..42774bc --- /dev/null +++ b/src/pages/setup/loading-dataset-view.tsx @@ -0,0 +1,70 @@ +import { Alert, AlertDescription, AlertTitle } from "@/components/ui" +import { Progress } from "@/components/ui/progress" +import { useDatasetDirectory } from "@/hooks/provider/dataset-directory-provider" +import { useImageImporter } from "@/hooks/use-image-importer" +import { useDatabase } from "@/lib/database/database-provider" +import { LoaderCircle } from "lucide-react" +import { useEffect, useRef, useState } from "react" + +type LoadingStep = "dataset" | "database" | "import" + +export const LoadingDatasetView = ({ onDone, onEmptyDataset, directoryHandle }: { onDone: VoidFunction, onEmptyDataset: VoidFunction, directoryHandle: FileSystemDirectoryHandle }) => { + const { importImages } = useImageImporter() + const { initializeDatabase } = useDatabase() + const { loadDirectory } = useDatasetDirectory() + const [step, setStep] = useState("dataset") + const progress = step === "dataset" ? 10 : step === "database" ? 50 : 80 + + const hasInitialized = useRef(false) + + useEffect(() => { + if (hasInitialized.current) { + return + } + hasInitialized.current = true + + const loadDataset = async () => { + try { + console.log("Loading directory") + const imageFiles = await loadDirectory(directoryHandle) + + if (imageFiles.length === 0) { + onEmptyDataset() + return + } + + console.log("Initializing database") + setStep("database") + const database = await initializeDatabase(directoryHandle) + const existingImages = await database.images.toArray() + + console.log("Existing images in backup", existingImages.length) + + console.log("Importing images", imageFiles.length) + setStep("import") + await importImages(database, existingImages, imageFiles) + + onDone() + } catch (error) { + console.error("Error during dataset loading:", error) + } + } + + loadDataset() + }, []) + + return ( + + + + {step === "dataset" && "Loading Dataset"} + {step === "database" && "Initializing Database"} + {step === "import" && "Importing Images"} + + + Hang tight! We are loading your dataset. Depending on the size this might take a while. + + + + ) +} \ No newline at end of file diff --git a/src/pages/setup/page.tsx b/src/pages/setup/page.tsx index 817ab73..9f87129 100644 --- a/src/pages/setup/page.tsx +++ b/src/pages/setup/page.tsx @@ -1,232 +1,51 @@ -import { - Alert, - AlertDescription, - AlertTitle, - Button, - Separator, -} from "@/components/ui"; import { BackgroundLines } from "@/components/ui/animation/background-lines"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { useState } from "react"; +import { SelectActionView } from "./select-action-view"; +import { SelectDirectoryView } from "./select-directory-view"; +import { LoadingDatasetView } from "./loading-dataset-view"; import { useDatasetDirectory } from "@/hooks/provider/dataset-directory-provider"; -import { - FileQuestion, - FolderOpen, - Image, - ImageOff, - LoaderCircle, - Pencil, - Tags, - TriangleAlert, -} from "lucide-react"; -import { ActionItem } from "./action-item"; -import { ActionSelector } from "./action-selector"; -import { DataPrivacyAlert } from "./data-privacy-alert"; -import { AutoSaveAlert } from "./auto-save-alert"; -import { AnimatePresence } from "framer-motion"; -import { useImageImporter } from "@/hooks/use-image-importer"; -import { useEffect } from "react"; -import { useDatabase } from "@/lib/database/database-provider"; -import LogoShadow from "@/assets/logo-shadow.png" -export default function Page() { - const { - supported, - openDirectoryPicker, - isDirectorySelected, - isDirectoryLoaded, - isEmpty, - isAccessDenied, - imageFiles, - textFiles, - failedImageFiles, - failedTextFiles, - reset, - } = useDatasetDirectory(); - const { isInitialized } = useDatabase() - const { importImages, imported } = useImageImporter() - - const isReady = isDirectorySelected && isDirectoryLoaded && !isEmpty && imported && isInitialized; - const isLoading = isDirectorySelected && !isDirectoryLoaded && !imported && !isInitialized; +type SetupStep = "select-directory" | "loading" | "select-action"; - useEffect(() => { - if (!isInitialized) { - return; - } - importImages(imageFiles); - }, [isInitialized, importImages, imageFiles]); +export default function Page() { + const [step, setStep] = useState("select-directory"); + const [directoryHandle, setDirectoryHandle] = useState(null); + const [isEmpty, setEmpty] = useState(false); + const { reset } = useDatasetDirectory() + + const handleCancel = () => { + reset() + setStep("select-directory") + } return (
- {isReady ? ( - - - Let's Go! - - Your dataset was loaded successfully. You can start working with - your images now. - - - -
-
- {imageFiles.length} Image - {imageFiles.length !== 1 && "s"} with {textFiles.length} label - {textFiles.length !== 1 && "s"} -
- {failedImageFiles > 0 && ( - <> - -
- -
-
- {failedImageFiles} Unsupported image - {failedImageFiles !== 1 && "s"} -
-
- Only JPEG, JPG and PNG are supported -
-
-
- - )} - {failedTextFiles > 0 && ( - <> - -
- -
-
- {failedTextFiles} Label{failedTextFiles !== 1 && "s"}{" "} - failed to load -
-
- They are not named after an image -
-
-
- - )} -
-

- What do you want to do? -

- - - - -
- - -
-
-
- ) : ( - <> - Logo - - - Welcome! - - To start labeling your images, select a directory from your - computer that contains all images. - - - - {isAccessDenied && ( - - - Access Denied! - - Your browser denied access to select a directory. Please - check your browser settings and reload the page. - - - )} - - {isDirectoryLoaded && isEmpty && ( - - - No images found - - The directory you selected does not contain any images. - Please select a different directory that contains JPEG, JPG - or PNG files. - - - )} - - {(!isDirectorySelected || isEmpty) && ( - <> - {!supported && ( - - - Unsupported Browser - -
- Your browser does not support the File System Access - API which is required by this app. Please use a - supported chromium-based browser, like Google Chrome, - Edge, Arc or Brave. - - Learn more ... - -
-
-
- )} - {supported && ( - - )} - - )} - - {isLoading && ( - - - Loading Dataset - - Hang tight! We are loading your dataset. Depending on the - size this might take a while. - - - )} -
-
+ {step === "select-action" && ( + + )} + {step === "loading" && directoryHandle && ( + setStep("select-action")} onEmptyDataset={() => { + setStep("select-directory") + setEmpty(true) + }} + /> + )} + {step === "select-directory" && ( + { + setEmpty(false) + setStep("loading") + setDirectoryHandle(handle) + }} + isEmpty={isEmpty} + /> )} - - - {isReady ? : } -
); diff --git a/src/pages/setup/select-action-view.tsx b/src/pages/setup/select-action-view.tsx new file mode 100644 index 0000000..5081fe3 --- /dev/null +++ b/src/pages/setup/select-action-view.tsx @@ -0,0 +1,97 @@ +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Separator } from "@/components/ui" +import { FileQuestion, Image, ImageOff, Pencil, Tags } from "lucide-react" +import { ActionSelector } from "./action-selector" +import { ActionItem } from "./action-item" +import { useDatasetDirectory } from "@/hooks/provider/dataset-directory-provider" + +export const SelectActionView = ({ + onCancel +}: { + onCancel: VoidFunction +}) => { + const { + imageFiles, + textFiles, + failedImageFiles, + failedTextFiles, + } = useDatasetDirectory(); + + return ( + + + Let's Go! + + Your dataset was loaded successfully. You can start working with + your images now. + + + +
+
+ {imageFiles.length} Image + {imageFiles.length !== 1 && "s"} with {textFiles.length} label + {textFiles.length !== 1 && "s"} +
+ {failedImageFiles > 0 && ( + <> + +
+ +
+
+ {failedImageFiles} Unsupported image + {failedImageFiles !== 1 && "s"} +
+
+ Only JPEG, JPG and PNG are supported +
+
+
+ + )} + {failedTextFiles > 0 && ( + <> + +
+ +
+
+ {failedTextFiles} Label{failedTextFiles !== 1 && "s"}{" "} + failed to load +
+
+ They are not named after an image +
+
+
+ + )} +
+

+ What do you want to do? +

+ + + + +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/setup/select-directory-view.tsx b/src/pages/setup/select-directory-view.tsx new file mode 100644 index 0000000..8df9ea3 --- /dev/null +++ b/src/pages/setup/select-directory-view.tsx @@ -0,0 +1,71 @@ +import { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui" +import LogoShadow from "@/assets/logo-shadow.png" +import { FolderOpen, TriangleAlert } from "lucide-react" +import { UnsupportedBrowserAlert } from "./unsupported-browser-alert" +import { useDatasetDirectory } from "@/hooks/provider/dataset-directory-provider" + +export const SelectDirectoryView = ({ + onSelected, + isEmpty +}: { + onSelected: (handle: FileSystemDirectoryHandle) => void + isEmpty: boolean +}) => { + const { + supported, + openDirectoryPicker, + isAccessDenied, + } = useDatasetDirectory(); + + const handleSelectDirectory = async () => { + const directoryHandle = await openDirectoryPicker() + if (directoryHandle) { + onSelected(directoryHandle) + } + } + + return ( + <> + Logo + + + Welcome! + + To start labeling your images, select a directory from your + computer that contains all images. + + + + {isAccessDenied && ( + + + Access Denied! + + Your browser denied access to select a directory. Please + check your browser settings and reload the page. + + + )} + + {isEmpty && ( + + + No images found + + The directory you selected does not contain any images. + Please select a different directory that contains JPEG, JPG + or PNG files. + + + )} + + {supported ? ( + + ) : } + + + + ) +} \ No newline at end of file diff --git a/src/pages/setup/unsupported-browser-alert.tsx b/src/pages/setup/unsupported-browser-alert.tsx new file mode 100644 index 0000000..5c4053a --- /dev/null +++ b/src/pages/setup/unsupported-browser-alert.tsx @@ -0,0 +1,26 @@ +import { Alert, AlertDescription, AlertTitle } from "@/components/ui" +import { TriangleAlert } from "lucide-react" + +export const UnsupportedBrowserAlert = () => { + return ( + + + Unsupported Browser + +
+ Your browser does not support the File System Access + API which is required by this app. Please use a + supported chromium-based browser, like Google Chrome, + Edge, Arc or Brave. + + Learn more ... + +
+
+
+ ) +} \ No newline at end of file