From dc68b5bbc07a5f1e6efe197acdc946c6be628f98 Mon Sep 17 00:00:00 2001 From: deepaerial Date: Mon, 16 Mar 2026 23:47:50 +0100 Subject: [PATCH 1/2] Breakdown to components --- frontend/src/components/App.tsx | 201 ++++++--------------- frontend/src/components/EmptyStates.tsx | 47 +++++ frontend/src/components/ExportControls.tsx | 54 ++++++ frontend/src/components/Header.tsx | 33 ++++ frontend/src/components/StatusFeedback.tsx | 37 ++++ 5 files changed, 226 insertions(+), 146 deletions(-) create mode 100644 frontend/src/components/EmptyStates.tsx create mode 100644 frontend/src/components/ExportControls.tsx create mode 100644 frontend/src/components/Header.tsx create mode 100644 frontend/src/components/StatusFeedback.tsx diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index eb80466..2814e66 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -1,16 +1,19 @@ import { useState, useEffect, useMemo } from "react"; -import { HardDrive, FolderOpen, LoaderCircle, TriangleAlert } from "lucide-react"; import type { MediaFile } from "./FileTable"; import { FileTable } from "./FileTable"; import { EventsOn } from "../../wailsjs/runtime/runtime"; -import SelectVolume from "./SelectVolume"; +import Header from "./Header"; +import ExportControls from "./ExportControls"; +import StatusFeedback from "./StatusFeedback"; +import EmptyStates from "./EmptyStates"; + import { GetMediaFilesForVolume, ChooseDestinationFolder, ExportFiles, CheckIfFilesAlreadyExported, - GetDefaultExportDestination + GetDefaultExportDestination, } from "../../wailsjs/go/main/App"; import { useErrorMessage } from "@/hooks"; @@ -24,7 +27,7 @@ export interface ExportProgressPayload { } export interface AppProps { - version: string; + version: string; } export default function App({ version }: AppProps) { @@ -33,8 +36,8 @@ export default function App({ version }: AppProps) { const [mediaFiles, setMediaFiles] = useState([]); const [isExporting, setIsExporting] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [exportError, setExportError] = useState(null); // New state for export errors - const [exportSuccess, setExportSuccess] = useState(false); // New state for export success + const [exportError, setExportError] = useState(null); + const [exportSuccess, setExportSuccess] = useState(false); const [exportProgress, setExportProgress] = useState< Record >({}); @@ -57,7 +60,9 @@ export default function App({ version }: AppProps) { if (payload.percentage === 100) { setMediaFiles((prevFiles) => prevFiles.map((f) => - f.filename === payload.fileName ? { ...f, status: "completed", exportPath: payload.filePath } : f, + f.filename === payload.fileName + ? { ...f, status: "completed", exportPath: payload.filePath } + : f, ), ); } else @@ -67,30 +72,35 @@ export default function App({ version }: AppProps) { const mediaFilesFingerprint = useMemo( () => mediaFiles.map((f) => f.path).join("|"), - [mediaFiles] + [mediaFiles], ); - useEffect(() => { - if (exportDestination && mediaFiles.length > 0) { - CheckIfFilesAlreadyExported(mediaFiles, exportDestination) - .then((alreadyExported) => { - console.log("Already exported files:", alreadyExported); - setMediaFiles((prevFiles) => - prevFiles.map((f) => { - const updatedFile = alreadyExported.find((ef) => ef.path === f.path); - if (updatedFile) { - return { ...f, status: "completed", exportPath: updatedFile.exportPath }; - } - return {...f, status: 'found'}; - }) - ); - }) - .catch((err) => { - setUserInputErrorMessage(`Error checking existing exports: ${err}`); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [exportDestination, mediaFilesFingerprint]); + useEffect(() => { + if (exportDestination && mediaFiles.length > 0) { + CheckIfFilesAlreadyExported(mediaFiles, exportDestination) + .then((alreadyExported) => { + setMediaFiles((prevFiles) => + prevFiles.map((f) => { + const updatedFile = alreadyExported.find( + (ef) => ef.path === f.path, + ); + if (updatedFile) { + return { + ...f, + status: "completed", + exportPath: updatedFile.exportPath, + }; + } + return { ...f, status: "found" }; + }), + ); + }) + .catch((err) => { + setUserInputErrorMessage(`Error checking existing exports: ${err}`); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [exportDestination, mediaFilesFingerprint]); const handleVolumeChange = (volumePath: string) => { if (volumePath === "") { @@ -141,7 +151,6 @@ export default function App({ version }: AppProps) { }; const handleChooseDestinationClick = async () => { - // Changed name to avoid conflict, added async setExportError(null); setExportSuccess(false); try { @@ -207,95 +216,21 @@ export default function App({ version }: AppProps) { return (
- {/* Header */} -
-
-
- ┌─────────────────────────────────────────────────────────────────┐ -
-
- │ DVR FOOTAGE EXPORTER {version} -
{" "} - {/* TODO: Add version number from wails.json */} -
- │ Select mounted volume and export DVR media files -
-
- └─────────────────────────────────────────────────────────────────┘ -
-
- {userInputError && ( -
- - {userInputError} -
) - } -
+
- {/* Controls */}
-
- {/* Volume Selector */} -
- - -
+ - {/* Export Destination */} -
- -
- - -
-
-
+ - {/* Status/Feedback */} - {(exportError || exportSuccess || isExporting) && ( -
- {isExporting && ( -
- - EXPORTING FILES... -
- )} - {exportError && ( -
- [ERROR] {exportError} -
- )} - {exportSuccess && ( -
- [SUCCESS] Files exported - successfully! -
- )} -
- )} - - {/* Removed old export button logic */}
{selectedVolume && mediaFiles.length > 0 && ( @@ -305,7 +240,6 @@ export default function App({ version }: AppProps) {
- {/* Media Files Table */} {selectedVolume && mediaFiles.length > 0 && (
@@ -324,36 +258,11 @@ export default function App({ version }: AppProps) {
)} - {/* Empty State */} - {selectedVolume && mediaFiles.length === 0 && ( -
- {isLoading ? ( - - ) : ( - - )} -
- {isLoading ? "SCANNING..." : "[WARNING] NO MEDIA FILES FOUND"} -
- {!isLoading && mediaFiles.length === 0 && ( -
- No DVR footage detected on selected volume. -
- )} -
- )} - - {!selectedVolume && ( -
- -
- > AWAITING INPUT -
-
- Select a mounted volume to scan for DVR footage. -
-
- )} +
); diff --git a/frontend/src/components/EmptyStates.tsx b/frontend/src/components/EmptyStates.tsx new file mode 100644 index 0000000..3f1dab6 --- /dev/null +++ b/frontend/src/components/EmptyStates.tsx @@ -0,0 +1,47 @@ +import { HardDrive, LoaderCircle } from "lucide-react"; + +interface EmptyStatesProps { + selectedVolume: string; + mediaFilesCount: number; + isLoading: boolean; +} + +export default function EmptyStates({ + selectedVolume, + mediaFilesCount, + isLoading, +}: EmptyStatesProps) { + if (!selectedVolume) { + return ( +
+ +
> AWAITING INPUT
+
+ Select a mounted volume to scan for DVR footage. +
+
+ ); + } + + if (mediaFilesCount === 0) { + return ( +
+ {isLoading ? ( + + ) : ( + + )} +
+ {isLoading ? "SCANNING..." : "[WARNING] NO MEDIA FILES FOUND"} +
+ {!isLoading && ( +
+ No DVR footage detected on selected volume. +
+ )} +
+ ); + } + + return null; +} diff --git a/frontend/src/components/ExportControls.tsx b/frontend/src/components/ExportControls.tsx new file mode 100644 index 0000000..c503948 --- /dev/null +++ b/frontend/src/components/ExportControls.tsx @@ -0,0 +1,54 @@ +import { HardDrive, FolderOpen } from "lucide-react"; +import SelectVolume from "./SelectVolume"; + +interface ExportControlsProps { + exportDestination: string; + onChooseDestination: () => void; + onVolumeChange: (volumePath: string) => void; +} + +export default function ExportControls({ + exportDestination, + onChooseDestination, + onVolumeChange, +}: ExportControlsProps) { + return ( +
+ {/* Volume Selector */} +
+ + +
+ + {/* Export Destination */} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..23b5605 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,33 @@ +import { TriangleAlert } from "lucide-react"; + +interface HeaderProps { + version: string; + userInputError: string | null; +} + +export default function Header({ version, userInputError }: HeaderProps) { + return ( +
+
+
+ ┌─────────────────────────────────────────────────────────────────┐ +
+
+ │ DVR FOOTAGE EXPORTER {version} +
+
+ │ Select mounted volume and export DVR media files +
+
+ └─────────────────────────────────────────────────────────────────┘ +
+
+ {userInputError && ( +
+ + {userInputError} +
+ )} +
+ ); +} diff --git a/frontend/src/components/StatusFeedback.tsx b/frontend/src/components/StatusFeedback.tsx new file mode 100644 index 0000000..bb9a77c --- /dev/null +++ b/frontend/src/components/StatusFeedback.tsx @@ -0,0 +1,37 @@ +import { LoaderCircle } from "lucide-react"; + +interface StatusFeedbackProps { + isExporting: boolean; + exportError: string | null; + exportSuccess: boolean; +} + +export default function StatusFeedback({ + isExporting, + exportError, + exportSuccess, +}: StatusFeedbackProps) { + if (!isExporting && !exportError && !exportSuccess) return null; + + return ( +
+ {isExporting && ( +
+ + EXPORTING FILES... +
+ )} + {exportError && ( +
+ [ERROR] {exportError} +
+ )} + {exportSuccess && ( +
+ [SUCCESS] Files exported + successfully! +
+ )} +
+ ); +} From 90330f04b12b2a6f460bc2bc5df16f90fa33299f Mon Sep 17 00:00:00 2001 From: deepaerial Date: Tue, 17 Mar 2026 00:30:25 +0100 Subject: [PATCH 2/2] refactor wip --- frontend/src/components/App.tsx | 195 +++++++++++++++----------- frontend/src/components/FileRow.tsx | 94 +++++++++++++ frontend/src/components/FileTable.tsx | 111 ++++----------- 3 files changed, 233 insertions(+), 167 deletions(-) create mode 100644 frontend/src/components/FileRow.tsx diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 2814e66..3cfb123 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect, useMemo } from "react"; -import type { MediaFile } from "./FileTable"; +import { useState, useEffect } from "react"; +import type { FileState } from "./FileTable"; import { FileTable } from "./FileTable"; import { EventsOn } from "../../wailsjs/runtime/runtime"; +import { main } from "../../wailsjs/go/models"; import Header from "./Header"; import ExportControls from "./ExportControls"; @@ -33,7 +34,8 @@ export interface AppProps { export default function App({ version }: AppProps) { const [selectedVolume, setSelectedVolume] = useState(""); const [exportDestination, setExportDestination] = useState(""); - const [mediaFiles, setMediaFiles] = useState([]); + const [mediaFiles, setMediaFiles] = useState([]); + const [fileStates, setFileStates] = useState>({}); const [isExporting, setIsExporting] = useState(false); const [isLoading, setIsLoading] = useState(false); const [exportError, setExportError] = useState(null); @@ -43,6 +45,40 @@ export default function App({ version }: AppProps) { >({}); const [userInputError, setUserInputErrorMessage] = useErrorMessage(); + const checkExports = async ( + filesToCheck: main.MediaFile[], + destination: string, + ) => { + try { + const alreadyExported = await CheckIfFilesAlreadyExported( + filesToCheck, + destination, + ); + setFileStates((prevStates) => { + const nextStates = { ...prevStates }; + filesToCheck.forEach((f) => { + const updatedFile = alreadyExported.find((ef) => ef.path === f.path); + if (updatedFile) { + nextStates[f.path] = { + ...nextStates[f.path], + status: "completed", + exportPath: updatedFile.exportPath, + }; + } else if (nextStates[f.path]?.status !== "exporting") { + nextStates[f.path] = { + ...nextStates[f.path], + status: "found", + exportPath: "", + }; + } + }); + return nextStates; + }); + } catch (err) { + setUserInputErrorMessage(`Error checking existing exports: ${err}`); + } + }; + useEffect(() => { GetDefaultExportDestination() .then((dest) => { @@ -51,61 +87,42 @@ export default function App({ version }: AppProps) { } }) .catch((err) => { - console.error("Error getting default export destination:", err); + setUserInputErrorMessage( + `Error getting default export destination: ${err}`, + ); }); - }, []); + }, [setUserInputErrorMessage]); useEffect(() => { EventsOn("export-progress", (payload: ExportProgressPayload) => { if (payload.percentage === 100) { - setMediaFiles((prevFiles) => - prevFiles.map((f) => - f.filename === payload.fileName - ? { ...f, status: "completed", exportPath: payload.filePath } - : f, - ), - ); + setFileStates((prev) => { + // Find file path by filename (assuming filenames are unique within current view) + const filePath = Object.keys(prev).find((path) => + path.endsWith(payload.fileName), + ); + if (filePath) { + return { + ...prev, + [filePath]: { + ...prev[filePath], + status: "completed", + exportPath: payload.filePath, + }, + }; + } + return prev; + }); } else setExportProgress((prev) => ({ ...prev, [payload.fileName]: payload })); }); }, []); - const mediaFilesFingerprint = useMemo( - () => mediaFiles.map((f) => f.path).join("|"), - [mediaFiles], - ); - - useEffect(() => { - if (exportDestination && mediaFiles.length > 0) { - CheckIfFilesAlreadyExported(mediaFiles, exportDestination) - .then((alreadyExported) => { - setMediaFiles((prevFiles) => - prevFiles.map((f) => { - const updatedFile = alreadyExported.find( - (ef) => ef.path === f.path, - ); - if (updatedFile) { - return { - ...f, - status: "completed", - exportPath: updatedFile.exportPath, - }; - } - return { ...f, status: "found" }; - }), - ); - }) - .catch((err) => { - setUserInputErrorMessage(`Error checking existing exports: ${err}`); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [exportDestination, mediaFilesFingerprint]); - const handleVolumeChange = (volumePath: string) => { if (volumePath === "") { setSelectedVolume(""); setMediaFiles([]); + setFileStates({}); setIsLoading(false); setExportProgress({}); return; @@ -118,17 +135,20 @@ export default function App({ version }: AppProps) { GetMediaFilesForVolume(volumePath) .then((files) => { - const converted = files.map((file) => ({ - path: file.path, - filename: file.filename, - size: file.size, - status: "found", - duration: file.duration, - exportPath: file.exportPath, - isChecked: true, // by default, all files are checked for export - })); - setMediaFiles(converted); + setMediaFiles(files); + const initialStates: Record = {}; + files.forEach((f) => { + initialStates[f.path] = { + status: "found", + exportPath: f.exportPath, + isChecked: true, + }; + }); + setFileStates(initialStates); setIsLoading(false); + if (exportDestination) { + checkExports(files, exportDestination); + } }) .catch((err) => { console.error("Error getting media files:", err); @@ -138,16 +158,21 @@ export default function App({ version }: AppProps) { setIsExporting(false); }; - const onCheckChange = (file: MediaFile, isChecked: boolean) => { - const updatedFiles = mediaFiles.map((f) => - f.path === file.path ? { ...f, isChecked } : f, - ); - setMediaFiles(updatedFiles); + const onCheckChange = (file: main.MediaFile, isChecked: boolean) => { + setFileStates((prev) => ({ + ...prev, + [file.path]: { ...prev[file.path], isChecked }, + })); }; const onCheckToggleAll = (isChecked: boolean) => { - const updatedFiles = mediaFiles.map((f) => ({ ...f, isChecked })); - setMediaFiles(updatedFiles); + setFileStates((prev) => { + const next = { ...prev }; + Object.keys(next).forEach((path) => { + next[path] = { ...next[path], isChecked }; + }); + return next; + }); }; const handleChooseDestinationClick = async () => { @@ -157,6 +182,9 @@ export default function App({ version }: AppProps) { const folderPath = await ChooseDestinationFolder(); if (folderPath) { setExportDestination(folderPath); + if (mediaFiles.length > 0) { + checkExports(mediaFiles, folderPath); + } } } catch (err) { console.error("Error choosing destination folder:", err); @@ -164,7 +192,7 @@ export default function App({ version }: AppProps) { } }; - const handleExportSelected = async (selectedFiles: MediaFile[]) => { + const handleExportSelected = async (selectedFiles: main.MediaFile[]) => { setExportError(null); setExportSuccess(false); setExportProgress({}); @@ -179,35 +207,35 @@ export default function App({ version }: AppProps) { } setIsExporting(true); - setMediaFiles((prevFiles) => - prevFiles.map((f) => - selectedFiles.some((sf) => sf.path === f.path) - ? { ...f, status: "exporting" } - : f, - ), - ); + setFileStates((prev) => { + const next = { ...prev }; + selectedFiles.forEach((f) => { + next[f.path] = { ...next[f.path], status: "exporting" }; + }); + return next; + }); try { const filePaths = selectedFiles.map((file) => file.path); await ExportFiles(filePaths, exportDestination); setExportSuccess(true); - setMediaFiles((prevFiles) => - prevFiles.map((f) => - selectedFiles.some((sf) => sf.path === f.path) - ? { ...f, status: "completed" } - : f, - ), - ); + setFileStates((prev) => { + const next = { ...prev }; + selectedFiles.forEach((f) => { + next[f.path] = { ...next[f.path], status: "completed" }; + }); + return next; + }); } catch (err) { console.error("Error during export:", err); setExportError(`Export failed: ${err}`); - setMediaFiles((prevFiles) => - prevFiles.map((f) => - selectedFiles.some((sf) => sf.path === f.path) - ? { ...f, status: "found" } - : f, - ), - ); + setFileStates((prev) => { + const next = { ...prev }; + selectedFiles.forEach((f) => { + next[f.path] = { ...next[f.path], status: "found" }; + }); + return next; + }); } finally { setIsExporting(false); } @@ -249,6 +277,7 @@ export default function App({ version }: AppProps) {
void; + onShowInFileSystem: (exportPath: string) => void; +} + +const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB"; + if (bytes < 1024 * 1024 * 1024) + return (bytes / (1024 * 1024)).toFixed(2) + " MB"; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"; +}; + +export const FileRow = memo(function FileRow({ + file, + status, + exportPath, + isChecked, + percentage, + onCheckChange, + onShowInFileSystem, +}: FileRowProps) { + const fileShouldExist = status === "completed" && exportPath !== ""; + + return ( + + +
+ + { + onCheckChange(file, e.target.checked); + }} + /> + {file.filename} +
+ + + {formatFileSize(file.size)} + + {file.duration} + + {status === "completed" && ( +
+ + [OK] +
+ )} + {status === "exporting" && ( +
+
+ [EXPORTING...] + {percentage}% +
+
+
+
+
+ )} + {status === "found" && ( + [FOUND] + )} + {status === "pending" && ( + [PENDING] + )} + + + + + + ); +}); diff --git a/frontend/src/components/FileTable.tsx b/frontend/src/components/FileTable.tsx index ca40fba..4fdc4c3 100644 --- a/frontend/src/components/FileTable.tsx +++ b/frontend/src/components/FileTable.tsx @@ -1,47 +1,39 @@ -import { Play, CheckCircle2, FolderOpen, Download } from "lucide-react"; +import { Download } from "lucide-react"; import type { ExportProgressPayload } from "./App"; import { ShowFileInFilesystem } from "../../wailsjs/go/main/App"; +import { main } from "../../wailsjs/go/models"; +import { FileRow } from "./FileRow"; -export interface MediaFile { - path: string; - filename: string; - size: number; +export interface FileState { status: string; exportPath: string; - duration: number; - isChecked?: boolean; + isChecked: boolean; } interface FileTableProps { - files: MediaFile[]; + files: main.MediaFile[]; + fileStates: Record; isExportButtonDisabled: boolean; - onCheckChange: (file: MediaFile, isChecked: boolean) => void; - onExportSelected: (selectedFiles: MediaFile[]) => void; + onCheckChange: (file: main.MediaFile, isChecked: boolean) => void; + onExportSelected: (selectedFiles: main.MediaFile[]) => void; onCheckToggleAll: (isChecked: boolean) => void; exportProgress: Record; } export function FileTable({ files, + fileStates, isExportButtonDisabled = false, onCheckChange, onExportSelected, onCheckToggleAll, exportProgress, }: FileTableProps) { - const formatFileSize = (bytes: number): string => { - if (bytes < 1024) return bytes + " B"; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB"; - if (bytes < 1024 * 1024 * 1024) - return (bytes / (1024 * 1024)).toFixed(2) + " MB"; - return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"; - }; - const handleShowInFileSystem = async (filePath: string) => { await ShowFileInFilesystem(filePath); }; - const selectedFiles = files.filter((file) => file.isChecked); + const selectedFiles = files.filter((file) => fileStates[file.path]?.isChecked); const allFilesSelected = selectedFiles.length > 0; isExportButtonDisabled = isExportButtonDisabled || selectedFiles.length === 0; @@ -97,75 +89,26 @@ export function FileTable({ - {files.map((file, id) => { + {files.map((file) => { + const state = fileStates[file.path] || { + status: "found", + exportPath: "", + isChecked: false, + }; const progress = exportProgress[file.filename]; const percentage = progress ? progress.percentage : 0; - const fileShouldExist = file.status === "completed" && file.exportPath !== ""; return ( - - -
- - { - onCheckChange(file, e.target.checked); - }} - /> - {file.filename} -
- - - {formatFileSize(file.size)} - - - {file.duration} - - - {file.status === "completed" && ( -
- - [OK] -
- )} - {file.status === "exporting" && ( -
-
- [EXPORTING...] - {percentage}% -
-
-
-
-
- )} - {file.status === "found" && ( - [FOUND] - )} - {file.status === "pending" && ( - [PENDING] - )} - - - - - + ); })}