diff --git a/src/components/model-selector/DownloadProgressDisplay.tsx b/src/components/model-selector/DownloadProgressDisplay.tsx new file mode 100644 index 0000000..4a994cb --- /dev/null +++ b/src/components/model-selector/DownloadProgressDisplay.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { ProgressBar, ProgressData } from "../shared"; + +interface DownloadProgress { + model_id: string; + downloaded: number; + total: number; + percentage: number; +} + +interface DownloadStats { + startTime: number; + lastUpdate: number; + totalDownloaded: number; + speed: number; +} + +interface DownloadProgressDisplayProps { + downloadProgress: Map; + downloadStats: Map; + className?: string; +} + +const DownloadProgressDisplay: React.FC = ({ + downloadProgress, + downloadStats, + className = "", +}) => { + if (downloadProgress.size === 0) { + return null; + } + + const progressData: ProgressData[] = Array.from(downloadProgress.values()).map((progress) => { + const stats = downloadStats.get(progress.model_id); + return { + id: progress.model_id, + percentage: progress.percentage, + speed: stats?.speed, + }; + }); + + return ( + + ); +}; + +export default DownloadProgressDisplay; diff --git a/src/components/model-selector/ModelDropdown.tsx b/src/components/model-selector/ModelDropdown.tsx new file mode 100644 index 0000000..e2752d4 --- /dev/null +++ b/src/components/model-selector/ModelDropdown.tsx @@ -0,0 +1,219 @@ +import React from "react"; +import { ModelInfo } from "../../lib/types"; +import { ProgressBar, ProgressData } from "../shared"; + +interface DownloadProgress { + model_id: string; + downloaded: number; + total: number; + percentage: number; +} + +interface ModelDropdownProps { + models: ModelInfo[]; + currentModelId: string; + downloadProgress: Map; + onModelSelect: (modelId: string) => void; + onModelDownload: (modelId: string) => void; + onModelDelete: (modelId: string) => Promise; + onError?: (error: string) => void; +} + +const ModelDropdown: React.FC = ({ + models, + currentModelId, + downloadProgress, + onModelSelect, + onModelDownload, + onModelDelete, + onError, +}) => { + const availableModels = models.filter((m) => m.is_downloaded); + const downloadableModels = models.filter((m) => !m.is_downloaded); + const isFirstRun = availableModels.length === 0 && models.length > 0; + + const handleDeleteClick = async (e: React.MouseEvent, modelId: string) => { + e.preventDefault(); + e.stopPropagation(); + + try { + await onModelDelete(modelId); + } catch (err) { + const errorMsg = `Failed to delete model: ${err}`; + onError?.(errorMsg); + } + }; + + const handleModelClick = (modelId: string) => { + if (downloadProgress.has(modelId)) { + return; // Don't allow interaction while downloading + } + onModelSelect(modelId); + }; + + const handleDownloadClick = (modelId: string) => { + if (downloadProgress.has(modelId)) { + return; // Don't allow interaction while downloading + } + onModelDownload(modelId); + }; + + return ( +
+ {/* First Run Welcome */} + {isFirstRun && ( +
+
+ Welcome to Handy! +
+
+ Download a model below to get started with transcription. +
+
+ )} + + {/* Available Models */} + {availableModels.length > 0 && ( +
+
+ Available Models +
+ {availableModels.map((model) => ( +
handleModelClick(model.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleModelClick(model.id); + } + }} + tabIndex={0} + role="button" + className={`w-full px-3 py-2 text-left hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${ + currentModelId === model.id + ? "bg-logo-primary/10 text-logo-primary" + : "" + }`} + > +
+
+
{model.name}
+
+ {model.description} +
+
+
+ {currentModelId === model.id && ( +
Active
+ )} + {currentModelId !== model.id && ( + + )} +
+
+
+ ))} +
+ )} + + {/* Downloadable Models */} + {downloadableModels.length > 0 && ( +
+ {(availableModels.length > 0 || isFirstRun) && ( +
+ )} +
+ {isFirstRun ? "Choose a Model" : "Download Models"} +
+ {downloadableModels.map((model) => { + const isDownloading = downloadProgress.has(model.id); + const progress = downloadProgress.get(model.id); + + return ( +
handleDownloadClick(model.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleDownloadClick(model.id); + } + }} + tabIndex={0} + role="button" + aria-disabled={isDownloading} + className={`w-full px-3 py-2 text-left hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${ + isDownloading + ? "opacity-50 cursor-not-allowed hover:bg-transparent" + : "" + }`} + > +
+
+
+ {model.name} + {model.id === "small" && isFirstRun && ( + + Recommended + + )} +
+
+ {model.description} +
+
+
+ {isDownloading && progress ? ( + `${Math.max(0, Math.min(100, Math.round(progress.percentage)))}%` + ) : ( + "Download" + )} +
+
+ + {isDownloading && progress && ( +
+ +
+ )} +
+ ); + })} +
+ )} + + {/* No Models Available */} + {availableModels.length === 0 && downloadableModels.length === 0 && ( +
+ No models available +
+ )} +
+ ); +}; + +export default ModelDropdown; diff --git a/src/components/model-selector/ModelSelector.tsx b/src/components/model-selector/ModelSelector.tsx index f10ffbd..0880923 100644 --- a/src/components/model-selector/ModelSelector.tsx +++ b/src/components/model-selector/ModelSelector.tsx @@ -2,6 +2,9 @@ import React, { useState, useRef, useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { ModelInfo } from "../../lib/types"; +import ModelStatusButton from "./ModelStatusButton"; +import ModelDropdown from "./ModelDropdown"; +import DownloadProgressDisplay from "./DownloadProgressDisplay"; interface ModelStateEvent { event_type: string; @@ -233,23 +236,6 @@ const ModelSelector: React.FC = ({ onError }) => { } }; - const getModelStatusColor = (status: ModelStatus): string => { - switch (status) { - case "ready": - return "bg-green-400"; - case "loading": - return "bg-yellow-400 animate-pulse"; - case "downloading": - return "bg-logo-primary animate-pulse"; - case "error": - return "bg-red-400"; - case "none": - return "bg-red-400"; - default: - return "bg-mid-gray/60"; - } - }; - const getCurrentModel = () => { return models.find((m) => m.id === currentModelId); }; @@ -284,319 +270,42 @@ const ModelSelector: React.FC = ({ onError }) => { } }; - const formatFileSize = (sizeMb: number): string => { - if (sizeMb < 1024) return `${sizeMb}MB`; - return `${(sizeMb / 1024).toFixed(1)}GB`; + const handleModelDelete = async (modelId: string) => { + await invoke("delete_model", { modelId }); + await loadModels(); + setModelError(null); }; - const getAvailableModels = () => { - return models.filter((m) => m.is_downloaded); - }; - - const getDownloadableModels = () => { - return models.filter((m) => !m.is_downloaded); - }; - - const availableModels = getAvailableModels(); - const downloadableModels = getDownloadableModels(); - const isFirstRun = availableModels.length === 0 && models.length > 0; - return ( <> {/* Model Status and Switcher */}
- + /> {/* Model Dropdown */} {showModelDropdown && ( -
- {/* First Run Welcome */} - {isFirstRun && ( -
-
- Welcome to Handy! -
-
- Download a model below to get started with transcription. -
-
- )} - - {/* Available Models */} - {availableModels.length > 0 && ( -
-
- Available Models -
- {availableModels.map((model) => ( -
handleModelSelect(model.id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleModelSelect(model.id); - } - }} - tabIndex={0} - role="button" - className={`w-full px-3 py-2 text-left hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${ - currentModelId === model.id - ? "bg-logo-primary/10 text-logo-primary" - : "" - }`} - > -
-
-
{model.name}
-
- {model.description} -
-
-
- {currentModelId === model.id && ( -
- Active -
- )} - {(() => { - const shouldShowDelete = currentModelId !== model.id; - return shouldShowDelete; - })() && ( - - )} -
-
-
- ))} -
- )} - - {/* Downloadable Models */} - {downloadableModels.length > 0 && ( -
- {(availableModels.length > 0 || isFirstRun) && ( -
- )} -
- {isFirstRun ? "Choose a Model" : "Download Models"} -
- {downloadableModels.map((model) => ( -
{ - if (modelDownloadProgress.has(model.id)) { - e.preventDefault(); - return; - } - handleModelDownload(model.id); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - if (modelDownloadProgress.has(model.id)) { - return; - } - handleModelDownload(model.id); - } - }} - tabIndex={0} - role="button" - aria-disabled={modelDownloadProgress.has(model.id)} - className={`w-full px-3 py-2 text-left hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${ - modelDownloadProgress.has(model.id) - ? "opacity-50 cursor-not-allowed hover:bg-transparent" - : "" - }`} - > -
-
-
- {model.name} - {model.id === "small" && isFirstRun && ( - - Recommended - - )} -
-
- {model.description} -
-
- {modelDownloadProgress.has(model.id) ? ( -
- {Math.max( - 0, - Math.min( - 100, - Math.round( - modelDownloadProgress.get(model.id)!.percentage, - ), - ), - )} - % -
- ) : ( -
- Download -
- )} -
- - {modelDownloadProgress.has(model.id) && ( -
-
-
-
-
- )} -
- ))} -
- )} - - {/* No Models Available */} - {availableModels.length === 0 && - downloadableModels.length === 0 && ( -
- No models available -
- )} -
+ )}
{/* Download Progress Bar for Models */} - {modelDownloadProgress.size > 0 && ( -
- {modelDownloadProgress.size === 1 ? ( - // Single download - show detailed progress - (() => { - const [progress] = Array.from(modelDownloadProgress.values()); - return ( - <> -
-
-
-
- {downloadStats.get(progress.model_id)?.speed && - downloadStats.get(progress.model_id)!.speed > 0 ? ( - - {downloadStats.get(progress.model_id)!.speed.toFixed(1)} - MB/s - - ) : ( - Downloading... - )} -
- - ); - })() - ) : ( - // Multiple downloads - show summary -
-
- {Array.from(modelDownloadProgress.values()).map( - (progress, index) => ( -
-
-
- ), - )} -
-
- {modelDownloadProgress.size} downloading... -
-
- )} -
- )} + ); }; diff --git a/src/components/model-selector/ModelStatusButton.tsx b/src/components/model-selector/ModelStatusButton.tsx new file mode 100644 index 0000000..c5a4373 --- /dev/null +++ b/src/components/model-selector/ModelStatusButton.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +type ModelStatus = "ready" | "loading" | "downloading" | "error" | "none"; + +interface ModelStatusButtonProps { + status: ModelStatus; + displayText: string; + isDropdownOpen: boolean; + onClick: () => void; + className?: string; +} + +const ModelStatusButton: React.FC = ({ + status, + displayText, + isDropdownOpen, + onClick, + className = "", +}) => { + const getStatusColor = (status: ModelStatus): string => { + switch (status) { + case "ready": + return "bg-green-400"; + case "loading": + return "bg-yellow-400 animate-pulse"; + case "downloading": + return "bg-logo-primary animate-pulse"; + case "error": + return "bg-red-400"; + case "none": + return "bg-red-400"; + default: + return "bg-mid-gray/60"; + } + }; + + return ( + + ); +}; + +export default ModelStatusButton; diff --git a/src/components/model-selector/index.ts b/src/components/model-selector/index.ts index 57f043e..07df1dd 100644 --- a/src/components/model-selector/index.ts +++ b/src/components/model-selector/index.ts @@ -1 +1,4 @@ -export { default } from './ModelSelector'; +export { default } from "./ModelSelector"; +export { default as ModelStatusButton } from "./ModelStatusButton"; +export { default as ModelDropdown } from "./ModelDropdown"; +export { default as DownloadProgressDisplay } from "./DownloadProgressDisplay"; diff --git a/src/components/shared/ProgressBar.tsx b/src/components/shared/ProgressBar.tsx new file mode 100644 index 0000000..3cb0ea0 --- /dev/null +++ b/src/components/shared/ProgressBar.tsx @@ -0,0 +1,89 @@ +import React from "react"; + +export interface ProgressData { + id: string; + percentage: number; + speed?: number; + label?: string; +} + +interface ProgressBarProps { + progress: ProgressData[]; + className?: string; + size?: "small" | "medium" | "large"; + showSpeed?: boolean; + showLabel?: boolean; +} + +const ProgressBar: React.FC = ({ + progress, + className = "", + size = "medium", + showSpeed = false, + showLabel = false, +}) => { + const sizeClasses = { + small: "w-16 h-1", + medium: "w-20 h-1.5", + large: "w-24 h-2", + }; + + const progressClasses = sizeClasses[size]; + + if (progress.length === 0) { + return null; + } + + if (progress.length === 1) { + // Single progress bar + const item = progress[0]; + const percentage = Math.max(0, Math.min(100, item.percentage)); + + return ( +
+ + {(showSpeed || showLabel) && ( +
+ {showLabel && item.label && ( + {item.label} + )} + {showSpeed && item.speed !== undefined && item.speed > 0 ? ( + {item.speed.toFixed(1)}MB/s + ) : showSpeed ? ( + Downloading... + ) : null} +
+ )} +
+ ); + } + + // Multiple progress bars + return ( +
+
+ {progress.map((item) => { + const percentage = Math.max(0, Math.min(100, item.percentage)); + return ( + + ); + })} +
+
+ {progress.length} downloading... +
+
+ ); +}; + +export default ProgressBar; diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts new file mode 100644 index 0000000..126aef7 --- /dev/null +++ b/src/components/shared/index.ts @@ -0,0 +1,2 @@ +export { default as ProgressBar } from './ProgressBar'; +export type { ProgressData } from './ProgressBar'; diff --git a/src/components/update-checker/UpdateChecker.tsx b/src/components/update-checker/UpdateChecker.tsx index 507d993..7b495f8 100644 --- a/src/components/update-checker/UpdateChecker.tsx +++ b/src/components/update-checker/UpdateChecker.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from "react"; import { check } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; import { listen } from "@tauri-apps/api/event"; +import { ProgressBar } from "../shared"; interface UpdateCheckerProps { className?: string; @@ -163,10 +164,14 @@ const UpdateChecker: React.FC = ({ className = "" }) => { )} {isInstalling && downloadProgress > 0 && downloadProgress < 100 && ( - )}