slight cleanup/refactor
This commit is contained in:
parent
9036c05088
commit
79d260c1c8
8 changed files with 462 additions and 321 deletions
52
src/components/model-selector/DownloadProgressDisplay.tsx
Normal file
52
src/components/model-selector/DownloadProgressDisplay.tsx
Normal file
|
|
@ -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<string, DownloadProgress>;
|
||||
downloadStats: Map<string, DownloadStats>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DownloadProgressDisplay: React.FC<DownloadProgressDisplayProps> = ({
|
||||
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 (
|
||||
<ProgressBar
|
||||
progress={progressData}
|
||||
className={className}
|
||||
showSpeed={downloadProgress.size === 1}
|
||||
size="medium"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadProgressDisplay;
|
||||
219
src/components/model-selector/ModelDropdown.tsx
Normal file
219
src/components/model-selector/ModelDropdown.tsx
Normal file
|
|
@ -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<string, DownloadProgress>;
|
||||
onModelSelect: (modelId: string) => void;
|
||||
onModelDownload: (modelId: string) => void;
|
||||
onModelDelete: (modelId: string) => Promise<void>;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
const ModelDropdown: React.FC<ModelDropdownProps> = ({
|
||||
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 (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-64 bg-background border border-mid-gray/20 rounded-lg shadow-lg py-2 z-50">
|
||||
{/* First Run Welcome */}
|
||||
{isFirstRun && (
|
||||
<div className="px-3 py-2 bg-logo-primary/10 border-b border-logo-primary/20">
|
||||
<div className="text-xs font-medium text-logo-primary mb-1">
|
||||
Welcome to Handy!
|
||||
</div>
|
||||
<div className="text-xs text-text/70">
|
||||
Download a model below to get started with transcription.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available Models */}
|
||||
{availableModels.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 py-1 text-xs font-medium text-text/80 border-b border-mid-gray/10">
|
||||
Available Models
|
||||
</div>
|
||||
{availableModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
onClick={() => 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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm">{model.name}</div>
|
||||
<div className="text-xs text-text/40 italic pr-4">
|
||||
{model.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentModelId === model.id && (
|
||||
<div className="text-xs text-logo-primary">Active</div>
|
||||
)}
|
||||
{currentModelId !== model.id && (
|
||||
<button
|
||||
onClick={(e) => handleDeleteClick(e, model.id)}
|
||||
className="text-red-400 hover:text-red-300 p-1 hover:bg-red-500/10 rounded transition-colors"
|
||||
title={`Delete ${model.name}`}
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Downloadable Models */}
|
||||
{downloadableModels.length > 0 && (
|
||||
<div>
|
||||
{(availableModels.length > 0 || isFirstRun) && (
|
||||
<div className="border-t border-mid-gray/10 my-1" />
|
||||
)}
|
||||
<div className="px-3 py-1 text-xs font-medium text-text/80">
|
||||
{isFirstRun ? "Choose a Model" : "Download Models"}
|
||||
</div>
|
||||
{downloadableModels.map((model) => {
|
||||
const isDownloading = downloadProgress.has(model.id);
|
||||
const progress = downloadProgress.get(model.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
onClick={() => 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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm">
|
||||
{model.name}
|
||||
{model.id === "small" && isFirstRun && (
|
||||
<span className="ml-2 text-xs bg-logo-primary/20 text-logo-primary px-1.5 py-0.5 rounded">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text/40 italic pr-4">
|
||||
{model.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-logo-primary tabular-nums">
|
||||
{isDownloading && progress ? (
|
||||
`${Math.max(0, Math.min(100, Math.round(progress.percentage)))}%`
|
||||
) : (
|
||||
"Download"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDownloading && progress && (
|
||||
<div className="mt-2">
|
||||
<ProgressBar
|
||||
progress={[{
|
||||
id: model.id,
|
||||
percentage: progress.percentage,
|
||||
label: model.name
|
||||
}]}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Models Available */}
|
||||
{availableModels.length === 0 && downloadableModels.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-text/60">
|
||||
No models available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDropdown;
|
||||
|
|
@ -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<ModelSelectorProps> = ({ 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<ModelSelectorProps> = ({ 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 */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
<ModelStatusButton
|
||||
status={modelStatus}
|
||||
displayText={getModelDisplayText()}
|
||||
isDropdownOpen={showModelDropdown}
|
||||
onClick={() => setShowModelDropdown(!showModelDropdown)}
|
||||
className="flex items-center gap-2 hover:text-text/80 transition-colors"
|
||||
title={`Model status: ${getModelDisplayText()}`}
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${getModelStatusColor(modelStatus)}`}
|
||||
/>
|
||||
<span className="max-w-28 truncate">{getModelDisplayText()}</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${showModelDropdown ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
/>
|
||||
|
||||
{/* Model Dropdown */}
|
||||
{showModelDropdown && (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-64 bg-background border border-mid-gray/20 rounded-lg shadow-lg py-2 z-50">
|
||||
{/* First Run Welcome */}
|
||||
{isFirstRun && (
|
||||
<div className="px-3 py-2 bg-logo-primary/10 border-b border-logo-primary/20">
|
||||
<div className="text-xs font-medium text-logo-primary mb-1">
|
||||
Welcome to Handy!
|
||||
</div>
|
||||
<div className="text-xs text-text/70">
|
||||
Download a model below to get started with transcription.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available Models */}
|
||||
{availableModels.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 py-1 text-xs font-medium text-text/80 border-b border-mid-gray/10">
|
||||
Available Models
|
||||
</div>
|
||||
{availableModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
onClick={() => 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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm">{model.name}</div>
|
||||
<div className="text-xs text-text/40 italic pr-4">
|
||||
{model.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentModelId === model.id && (
|
||||
<div className="text-xs text-logo-primary">
|
||||
Active
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const shouldShowDelete = currentModelId !== model.id;
|
||||
return shouldShowDelete;
|
||||
})() && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log(
|
||||
"Delete button clicked for model:",
|
||||
model.id,
|
||||
);
|
||||
console.log("Model info:", model);
|
||||
|
||||
try {
|
||||
console.log("Deleting model:", model.id);
|
||||
|
||||
const result = await invoke("delete_model", {
|
||||
modelId: model.id,
|
||||
});
|
||||
|
||||
console.log("Delete result:", result);
|
||||
|
||||
// Refresh models list but keep dropdown open
|
||||
await loadModels();
|
||||
setModelError(null);
|
||||
|
||||
console.log("Model deleted and UI refreshed");
|
||||
} catch (err) {
|
||||
console.error("Delete failed with error:", err);
|
||||
const errorMsg = `Failed to delete model: ${err}`;
|
||||
setModelError(errorMsg);
|
||||
setModelStatus("error");
|
||||
onError?.(errorMsg);
|
||||
}
|
||||
}}
|
||||
className="text-red-400 hover:text-red-300 p-1 hover:bg-red-500/10 rounded transition-colors"
|
||||
title={`Delete ${model.name}`}
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Downloadable Models */}
|
||||
{downloadableModels.length > 0 && (
|
||||
<div>
|
||||
{(availableModels.length > 0 || isFirstRun) && (
|
||||
<div className="border-t border-mid-gray/10 my-1" />
|
||||
)}
|
||||
<div className="px-3 py-1 text-xs font-medium text-text/80">
|
||||
{isFirstRun ? "Choose a Model" : "Download Models"}
|
||||
</div>
|
||||
{downloadableModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
onClick={(e) => {
|
||||
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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm">
|
||||
{model.name}
|
||||
{model.id === "small" && isFirstRun && (
|
||||
<span className="ml-2 text-xs bg-logo-primary/20 text-logo-primary px-1.5 py-0.5 rounded">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text/40 italic pr-4">
|
||||
{model.description}
|
||||
</div>
|
||||
</div>
|
||||
{modelDownloadProgress.has(model.id) ? (
|
||||
<div className="text-xs text-logo-primary tabular-nums">
|
||||
{Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
modelDownloadProgress.get(model.id)!.percentage,
|
||||
),
|
||||
),
|
||||
)}
|
||||
%
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-logo-primary">
|
||||
Download
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modelDownloadProgress.has(model.id) && (
|
||||
<div className="mt-2">
|
||||
<div className="w-full bg-mid-gray/20 rounded-full h-1.5">
|
||||
<div
|
||||
className="h-1.5 bg-logo-primary rounded-full transition-all duration-500 ease-out"
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, modelDownloadProgress.get(model.id)!.percentage))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Models Available */}
|
||||
{availableModels.length === 0 &&
|
||||
downloadableModels.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-text/60">
|
||||
No models available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ModelDropdown
|
||||
models={models}
|
||||
currentModelId={currentModelId}
|
||||
downloadProgress={modelDownloadProgress}
|
||||
onModelSelect={handleModelSelect}
|
||||
onModelDownload={handleModelDownload}
|
||||
onModelDelete={handleModelDelete}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download Progress Bar for Models */}
|
||||
{modelDownloadProgress.size > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
{modelDownloadProgress.size === 1 ? (
|
||||
// Single download - show detailed progress
|
||||
(() => {
|
||||
const [progress] = Array.from(modelDownloadProgress.values());
|
||||
return (
|
||||
<>
|
||||
<div className="w-16 h-1.5 bg-mid-gray/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-logo-primary transition-all duration-500 ease-out"
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, progress.percentage))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-text/60 tabular-nums min-w-fit">
|
||||
{downloadStats.get(progress.model_id)?.speed &&
|
||||
downloadStats.get(progress.model_id)!.speed > 0 ? (
|
||||
<span>
|
||||
{downloadStats.get(progress.model_id)!.speed.toFixed(1)}
|
||||
MB/s
|
||||
</span>
|
||||
) : (
|
||||
<span>Downloading...</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
// Multiple downloads - show summary
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{Array.from(modelDownloadProgress.values()).map(
|
||||
(progress, index) => (
|
||||
<div
|
||||
key={progress.model_id}
|
||||
className="w-3 h-1.5 bg-mid-gray/20 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-logo-primary transition-all duration-500 ease-out"
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, progress.percentage))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text/60 min-w-fit">
|
||||
{modelDownloadProgress.size} downloading...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DownloadProgressDisplay
|
||||
downloadProgress={modelDownloadProgress}
|
||||
downloadStats={downloadStats}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
62
src/components/model-selector/ModelStatusButton.tsx
Normal file
62
src/components/model-selector/ModelStatusButton.tsx
Normal file
|
|
@ -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<ModelStatusButtonProps> = ({
|
||||
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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-2 hover:text-text/80 transition-colors ${className}`}
|
||||
title={`Model status: ${displayText}`}
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full ${getStatusColor(status)}`} />
|
||||
<span className="max-w-28 truncate">{displayText}</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${isDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelStatusButton;
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
89
src/components/shared/ProgressBar.tsx
Normal file
89
src/components/shared/ProgressBar.tsx
Normal file
|
|
@ -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<ProgressBarProps> = ({
|
||||
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 (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
<progress
|
||||
value={percentage}
|
||||
max={100}
|
||||
className={`${progressClasses} [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-bar]:bg-mid-gray/20 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-logo-primary`}
|
||||
/>
|
||||
{(showSpeed || showLabel) && (
|
||||
<div className="text-xs text-text/60 tabular-nums min-w-fit">
|
||||
{showLabel && item.label && (
|
||||
<span className="mr-2">{item.label}</span>
|
||||
)}
|
||||
{showSpeed && item.speed !== undefined && item.speed > 0 ? (
|
||||
<span>{item.speed.toFixed(1)}MB/s</span>
|
||||
) : showSpeed ? (
|
||||
<span>Downloading...</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple progress bars
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<div className="flex gap-1">
|
||||
{progress.map((item) => {
|
||||
const percentage = Math.max(0, Math.min(100, item.percentage));
|
||||
return (
|
||||
<progress
|
||||
key={item.id}
|
||||
value={percentage}
|
||||
max={100}
|
||||
title={item.label || `${percentage}%`}
|
||||
className="w-3 h-1.5 [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-bar]:bg-mid-gray/20 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-logo-primary"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-text/60 min-w-fit">
|
||||
{progress.length} downloading...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressBar;
|
||||
2
src/components/shared/index.ts
Normal file
2
src/components/shared/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ProgressBar } from './ProgressBar';
|
||||
export type { ProgressData } from './ProgressBar';
|
||||
|
|
@ -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<UpdateCheckerProps> = ({ className = "" }) => {
|
|||
)}
|
||||
|
||||
{isInstalling && downloadProgress > 0 && downloadProgress < 100 && (
|
||||
<progress
|
||||
value={downloadProgress}
|
||||
max={100}
|
||||
className="w-20 h-2 [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-bar]:bg-mid-gray/20 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-logo-primary"
|
||||
<ProgressBar
|
||||
progress={[
|
||||
{
|
||||
id: "update",
|
||||
percentage: downloadProgress,
|
||||
},
|
||||
]}
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue