diff --git a/bun.lockb b/bun.lockb index 8fbc20a..2e86f7e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index b9db1bf..5ac94dd 100644 --- a/package.json +++ b/package.json @@ -23,12 +23,14 @@ "@tauri-apps/plugin-stronghold": "~2", "@tauri-apps/plugin-updater": "~2", "@tauri-apps/plugin-upload": "~2", + "lucide-react": "^0.542.0", "react": "^18.3.1", "react-dom": "^18.3.1", "sonner": "^2.0.7", "tailwindcss": "^4.0.2", "tauri-plugin-macos-permissions-api": "^2.0.4", - "zod": "^3.24.4" + "zod": "^3.24.4", + "zustand": "^5.0.8" }, "devDependencies": { "@tauri-apps/cli": "^2", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f073522..904664e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -14,10 +14,10 @@ "windows": [ { "title": "Handy", - "width": 540, - "height": 740, - "minWidth": 540, - "minHeight": 740, + "width": 680, + "height": 510, + "minWidth": 680, + "minHeight": 510, "resizable": true, "maximizable": false } diff --git a/src/App.tsx b/src/App.tsx index e62631c..b2c62fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,15 +6,50 @@ import AccessibilityPermissions from "./components/AccessibilityPermissions"; import Footer from "./components/footer"; import HandyTextLogo from "./components/icons/HandyTextLogo"; import Onboarding from "./components/onboarding"; -import { Settings } from "./components/settings/Settings"; +import { Sidebar, SidebarSection, SECTIONS_CONFIG } from "./components/Sidebar"; +import { useSettings } from "./hooks/useSettings"; + +const renderSettingsContent = (section: SidebarSection) => { + const ActiveComponent = + SECTIONS_CONFIG[section]?.component || SECTIONS_CONFIG.general.component; + return ; +}; function App() { const [showOnboarding, setShowOnboarding] = useState(null); + const [currentSection, setCurrentSection] = + useState("general"); + const { settings, updateSetting } = useSettings(); useEffect(() => { checkOnboardingStatus(); }, []); + // Handle keyboard shortcuts for debug mode toggle + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Check for Ctrl+Shift+D (Windows/Linux) or Cmd+Shift+D (macOS) + const isDebugShortcut = + event.shiftKey && + event.key.toLowerCase() === "d" && + (event.ctrlKey || event.metaKey); + + if (isDebugShortcut) { + event.preventDefault(); + const currentDebugMode = settings?.debug_mode ?? false; + updateSetting("debug_mode", !currentDebugMode); + } + }; + + // Add event listener when component mounts + document.addEventListener("keydown", handleKeyDown); + + // Cleanup event listener when component unmounts + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [settings?.debug_mode, updateSetting]); + const checkOnboardingStatus = async () => { try { // Always check if they have any models available @@ -33,23 +68,36 @@ function App() { if (showOnboarding) { return ( -
-
+
+
+
); } return ( -
+
-
- - - + {/* Main content area that takes remaining space */} +
+ + {/* Scrollable content area */} +
+
+
+ + {renderSettingsContent(currentSection)} +
+
+
+ {/* Fixed footer at bottom */}
); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..5ae0d59 --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { Cog, FlaskConical, History, Info } from "lucide-react"; +import HandyTextLogo from "./icons/HandyTextLogo"; +import HandyHand from "./icons/HandyHand"; +import { useSettings } from "../hooks/useSettings"; +import { + GeneralSettings, + AdvancedSettings, + HistorySettings, + DebugSettings, + AboutSettings, +} from "./settings"; + +export type SidebarSection = keyof typeof SECTIONS_CONFIG; + +interface IconProps { + width?: number | string; + height?: number | string; + size?: number | string; + className?: string; + [key: string]: any; +} + +interface SectionConfig { + label: string; + icon: React.ComponentType; + component: React.ComponentType; + enabled: (settings: any) => boolean; +} + +export const SECTIONS_CONFIG = { + general: { + label: "General", + icon: HandyHand, + component: GeneralSettings, + enabled: () => true, + }, + advanced: { + label: "Advanced", + icon: Cog, + component: AdvancedSettings, + enabled: () => true, + }, + // history: { + // label: "History", + // icon: History, + // component: HistorySettings, + // enabled: () => true, + // }, + debug: { + label: "Debug", + icon: FlaskConical, + component: DebugSettings, + enabled: (settings) => settings?.debug_mode ?? false, + }, + about: { + label: "About", + icon: Info, + component: AboutSettings, + enabled: () => true, + }, +} as const satisfies Record; + +interface SidebarProps { + activeSection: SidebarSection; + onSectionChange: (section: SidebarSection) => void; +} + +export const Sidebar: React.FC = ({ + activeSection, + onSectionChange, +}) => { + const { settings } = useSettings(); + + const availableSections = Object.entries(SECTIONS_CONFIG) + .filter(([_, config]) => config.enabled(settings)) + .map(([id, config]) => ({ id: id as SidebarSection, ...config })); + + return ( +
+ +
+ {availableSections.map((section) => { + const Icon = section.icon; + const isActive = activeSection === section.id; + + return ( +
onSectionChange(section.id)} + > + +

{section.label}

+
+ ); + })} +
+
+ ); +}; diff --git a/src/components/footer/Footer.tsx b/src/components/footer/Footer.tsx index 3022dfd..b901a04 100644 --- a/src/components/footer/Footer.tsx +++ b/src/components/footer/Footer.tsx @@ -22,7 +22,7 @@ const Footer: React.FC = () => { }, []); return ( -
+
diff --git a/src/components/icons/HandyHand.tsx b/src/components/icons/HandyHand.tsx new file mode 100644 index 0000000..fc115b6 --- /dev/null +++ b/src/components/icons/HandyHand.tsx @@ -0,0 +1,24 @@ +const HandyHand = ({ + width, + height, +}: { + width?: number | string; + height?: number | string; +}) => ( + + + +); + +export default HandyHand; diff --git a/src/components/icons/HandyTextLogo.tsx b/src/components/icons/HandyTextLogo.tsx index fc41bd5..008f89b 100644 --- a/src/components/icons/HandyTextLogo.tsx +++ b/src/components/icons/HandyTextLogo.tsx @@ -3,14 +3,17 @@ import React from "react"; const HandyTextLogo = ({ width, height, + className, }: { width?: number; height?: number; + className?: string; }) => { return ( { + const [version, setVersion] = useState(""); + + useEffect(() => { + const fetchVersion = async () => { + try { + const appVersion = await getVersion(); + setVersion(appVersion); + } catch (error) { + console.error("Failed to get app version:", error); + setVersion("0.1.2"); + } + }; + + fetchVersion(); + }, []); + + const handleDonateClick = async () => { + try { + await openUrl("https://handy.computer/donate"); + } catch (error) { + console.error("Failed to open donate link:", error); + } + }; + + return ( +
+ + + v{version} + + + + + + + + + +
+ Handy uses Whisper.cpp for fast, local speech-to-text processing. + Thanks to the amazing work by Georgi Gerganov and contributors. +
+
+
+
+ ); +}; diff --git a/src/components/settings/AdvancedSettings.tsx b/src/components/settings/AdvancedSettings.tsx new file mode 100644 index 0000000..2ca6c81 --- /dev/null +++ b/src/components/settings/AdvancedSettings.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { ShowOverlay } from "./ShowOverlay"; +import { TranslateToEnglish } from "./TranslateToEnglish"; +import { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout"; +import { CustomWords } from "./CustomWords"; +import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone"; +import { SettingsGroup } from "../ui/SettingsGroup"; + +export const AdvancedSettings: React.FC = () => { + return ( +
+ + + + + + + +
+ ); +}; diff --git a/src/components/settings/AudioFeedback.tsx b/src/components/settings/AudioFeedback.tsx index 36524c1..7aeb53d 100644 --- a/src/components/settings/AudioFeedback.tsx +++ b/src/components/settings/AudioFeedback.tsx @@ -15,15 +15,16 @@ export const AudioFeedback: React.FC = React.memo(({ const audioFeedbackEnabled = getSetting("audio_feedback") || false; - return ( - updateSetting("audio_feedback", enabled)} - isUpdating={isUpdating("audio_feedback")} - label="Audio Feedback" - description="Play sound when recording starts and stops" - descriptionMode={descriptionMode} - grouped={grouped} - /> - ); -}); + return ( + updateSetting("audio_feedback", enabled)} + isUpdating={isUpdating("audio_feedback")} + label="Audio Feedback" + description="Play sound when recording starts and stops" + descriptionMode={descriptionMode} + grouped={grouped} + /> + ); + }, +); diff --git a/src/components/settings/DebugSettings.tsx b/src/components/settings/DebugSettings.tsx new file mode 100644 index 0000000..55ae57d --- /dev/null +++ b/src/components/settings/DebugSettings.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { WordCorrectionThreshold } from "./debug/WordCorrectionThreshold"; +import { AppDataDirectory } from "./AppDataDirectory"; +import { SettingsGroup } from "../ui/SettingsGroup"; + +export const DebugSettings: React.FC = () => { + return ( +
+ + + + +
+ ); +}; diff --git a/src/components/settings/GeneralSettings.tsx b/src/components/settings/GeneralSettings.tsx new file mode 100644 index 0000000..5ff6af6 --- /dev/null +++ b/src/components/settings/GeneralSettings.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { MicrophoneSelector } from "./MicrophoneSelector"; +import { LanguageSelector } from "./LanguageSelector"; +import { HandyShortcut } from "./HandyShortcut"; +import { SettingsGroup } from "../ui/SettingsGroup"; +import { OutputDeviceSelector } from "./OutputDeviceSelector"; +import { PushToTalk } from "./PushToTalk"; +import { AudioFeedback } from "./AudioFeedback"; +import { useSettings } from "../../hooks/useSettings"; + +export const GeneralSettings: React.FC = () => { + const { audioFeedbackEnabled } = useSettings(); + return ( +
+ + + + + + + + + + +
+ ); +}; diff --git a/src/components/settings/HistorySettings.tsx b/src/components/settings/HistorySettings.tsx new file mode 100644 index 0000000..87fbad0 --- /dev/null +++ b/src/components/settings/HistorySettings.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { SettingsGroup } from "../ui/SettingsGroup"; + +export const HistorySettings: React.FC = () => { + return ( +
+ + <> + +
+ ); +}; diff --git a/src/components/settings/OutputDeviceSelector.tsx b/src/components/settings/OutputDeviceSelector.tsx index b0efd70..9613fd5 100644 --- a/src/components/settings/OutputDeviceSelector.tsx +++ b/src/components/settings/OutputDeviceSelector.tsx @@ -3,62 +3,79 @@ import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { ResetButton } from "../ui/ResetButton"; import { useSettings } from "../../hooks/useSettings"; +import { AudioDevice } from "../../lib/types"; interface OutputDeviceSelectorProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; + disabled?: boolean; } -export const OutputDeviceSelector: React.FC = React.memo(({ - descriptionMode = "tooltip", - grouped = false, -}) => { - const { - getSetting, - updateSetting, - resetSetting, - isUpdating, - isLoading, - outputDevices, - refreshOutputDevices, - } = useSettings(); +export const OutputDeviceSelector: React.FC = + React.memo( + ({ descriptionMode = "tooltip", grouped = false, disabled = false }) => { + const { + getSetting, + updateSetting, + resetSetting, + isUpdating, + isLoading, + outputDevices, + refreshOutputDevices, + } = useSettings(); - const selectedOutputDevice = getSetting("selected_output_device") === "default" ? "Default" : getSetting("selected_output_device") || "Default"; + const selectedOutputDevice = + getSetting("selected_output_device") === "default" + ? "Default" + : getSetting("selected_output_device") || "Default"; - const handleOutputDeviceSelect = async (deviceName: string) => { - await updateSetting("selected_output_device", deviceName); - }; + const handleOutputDeviceSelect = async (deviceName: string) => { + await updateSetting("selected_output_device", deviceName); + }; - const handleReset = async () => { - await resetSetting("selected_output_device"); - }; + const handleReset = async () => { + await resetSetting("selected_output_device"); + }; - const outputDeviceOptions = outputDevices.map(device => ({ - value: device.name, - label: device.name - })); + const outputDeviceOptions = outputDevices.map((device: AudioDevice) => ({ + value: device.name, + label: device.name, + })); - return ( - -
- - -
-
+ return ( + +
+ + +
+
+ ); + }, ); -}); diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx deleted file mode 100644 index db7bb4a..0000000 --- a/src/components/settings/Settings.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useEffect } from "react"; -import { MicrophoneSelector } from "./MicrophoneSelector"; -import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone"; -import { PushToTalk } from "./PushToTalk"; -import { AudioFeedback } from "./AudioFeedback"; -import { OutputDeviceSelector } from "./OutputDeviceSelector"; -import { ShowOverlay } from "./ShowOverlay"; -import { HandyShortcut } from "./HandyShortcut"; -import { TranslateToEnglish } from "./TranslateToEnglish"; -import { LanguageSelector } from "./LanguageSelector"; -import { CustomWords } from "./CustomWords"; -import { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout"; -import { SettingsGroup } from "../ui/SettingsGroup"; -import { WordCorrectionThreshold } from "./debug/WordCorrectionThreshold"; -import { AppDataDirectory } from "./AppDataDirectory"; -import { useSettings } from "../../hooks/useSettings"; - -export const Settings: React.FC = () => { - const { settings, updateSetting } = useSettings(); - - // Handle keyboard shortcuts for debug mode toggle - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - // Check for Ctrl+Shift+D (Windows/Linux) or Cmd+Shift+D (macOS) - const isDebugShortcut = - event.shiftKey && - event.key.toLowerCase() === "d" && - (event.ctrlKey || event.metaKey); - - if (isDebugShortcut) { - event.preventDefault(); - const currentDebugMode = settings?.debug_mode ?? false; - updateSetting("debug_mode", !currentDebugMode); - } - }; - - // Add event listener when component mounts - document.addEventListener("keydown", handleKeyDown); - - // Cleanup event listener when component unmounts - return () => { - document.removeEventListener("keydown", handleKeyDown); - }; - }, [settings?.debug_mode, updateSetting]); - - return ( -
- - - - - - - - - - - - - - - - - - {settings?.debug_mode && ( - - - - - )} -
- ); -}; diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index ec094de..19c7f69 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -1,4 +1,11 @@ -export { Settings } from "./Settings"; +// Settings section components +export { GeneralSettings } from "./GeneralSettings"; +export { AdvancedSettings } from "./AdvancedSettings"; +export { DebugSettings } from "./DebugSettings"; +export { HistorySettings } from "./HistorySettings"; +export { AboutSettings } from "./AboutSettings"; + +// Individual setting components export { MicrophoneSelector } from "./MicrophoneSelector"; export { OutputDeviceSelector } from "./OutputDeviceSelector"; export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone"; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 13462ca..abba817 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -17,7 +17,7 @@ export const Button: React.FC = ({ const variantClasses = { primary: - "text-white bg-background-ui hover:bg-background-ui/90 focus:ring-1 focus:ring-background-ui", + "text-white bg-background-ui hover:bg-background-ui/80 focus:ring-1 focus:ring-background-ui", secondary: "bg-mid-gray/10 hover:bg-background-ui/30 focus:outline-none", danger: "text-white bg-red-600 hover:bg-red-700 focus:ring-1 focus:ring-red-500", diff --git a/src/components/ui/ResetButton.tsx b/src/components/ui/ResetButton.tsx index 4f8a7d3..05328f2 100644 --- a/src/components/ui/ResetButton.tsx +++ b/src/components/ui/ResetButton.tsx @@ -10,7 +10,11 @@ interface ResetButtonProps { export const ResetButton: React.FC = React.memo( ({ onClick, disabled = false, className = "" }) => (