diff --git a/docs/privacy.html b/docs/privacy.html index dc2df45..01c0111 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -67,20 +67,22 @@ - + +
diff --git a/src/main/services/whisper.ts b/src/main/services/whisper.ts index d6a3e2b..29d440f 100644 --- a/src/main/services/whisper.ts +++ b/src/main/services/whisper.ts @@ -542,7 +542,7 @@ export function transcribe( const progressMatch = message.match(/progress\s*=\s*(\d+)%/); if (progressMatch && progressMatch[1]) { - const percent = parseInt(progressMatch[1], 10); + const percent = Math.min(100, parseInt(progressMatch[1], 10)); const scaledPercent = 20 + Math.round((percent / 100) * 70); onProgress?.({ percent: scaledPercent, status: `Transcribing... ${percent}%` }); } diff --git a/src/renderer/App.css b/src/renderer/App.css index f6e9f7b..223cc8d 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -71,25 +71,6 @@ border-color: var(--border-hover); } -.history-btn { - -webkit-app-region: no-drag; - padding: 8px 14px; - font-size: 0.85rem; - background: var(--surface); - border: 1px solid var(--border); - color: var(--text-primary); - border-radius: var(--radius-sm); - transition: all var(--transition-fast); - display: flex; - align-items: center; - gap: 6px; -} - -.history-btn:hover { - background: var(--surface-hover); - border-color: var(--border-hover); -} - .app-main { flex: 1; display: grid; @@ -130,92 +111,6 @@ gap: 12px; } -button { - font-family: inherit; - font-size: 0.95rem; - padding: 12px 20px; - border-radius: var(--radius-sm); - border: none; - cursor: pointer; - transition: all var(--transition-fast); - font-weight: 500; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - position: relative; -} - -button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.btn-primary { - background: linear-gradient(135deg, var(--accent), var(--accent-hover)); - color: white; - flex: 1; - box-shadow: var(--shadow-accent); -} - -.btn-primary::after { - content: ''; - position: absolute; - inset: 0; - border-radius: inherit; - box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.2); - pointer-events: none; -} - -.btn-primary:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: - 0 0 0 1px var(--accent-border), - 0 4px 16px var(--accent-glow), - 0 8px 32px rgba(37, 99, 235, 0.2); -} - -.btn-primary:active:not(:disabled) { - transform: scale(0.98); - box-shadow: var(--shadow-accent); -} - -.btn-danger { - background: var(--danger); - color: white; - flex: 1; -} - -.btn-danger:hover:not(:disabled) { - background: #dc2626; -} - -.btn-secondary { - background: var(--surface); - color: var(--text-primary); - border: 1px solid var(--border); - box-shadow: var(--shadow-inner); -} - -.btn-secondary:hover:not(:disabled) { - background: var(--surface-hover); - border-color: var(--border-hover); -} - -.btn-icon { - padding: 8px 12px; - font-size: 0.85rem; - background: var(--surface); - border: 1px solid var(--border); - color: var(--text-primary); - border-radius: var(--radius-sm); -} - -.btn-icon:hover:not(:disabled) { - background: var(--surface-hover); - border-color: var(--border-hover); -} - .error-message { background: var(--danger-light); border: 1px solid var(--danger); diff --git a/src/renderer/components/layout/AppHeader/AppHeader.tsx b/src/renderer/components/layout/AppHeader/AppHeader.tsx index 15025ad..39615ba 100644 --- a/src/renderer/components/layout/AppHeader/AppHeader.tsx +++ b/src/renderer/components/layout/AppHeader/AppHeader.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Moon, Sun, History, Terminal } from 'lucide-react'; +import { Button } from '../../ui'; import { useAppTheme, useAppHistory } from '../../../contexts'; import { useDebugLogs } from '../../../hooks'; import { DebugLogsModal } from '../../ui/DebugLogsModal'; @@ -30,34 +31,32 @@ function AppHeader(): React.JSX.Element {
- - - + History ({history.length}) +
diff --git a/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx b/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx index 27b9350..a31ca64 100644 --- a/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx +++ b/src/renderer/components/layout/LeftPanel/DonationSection/DonationSection.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Heart, Coffee } from 'lucide-react'; +import { Button } from '../../../../components/ui'; import { openExternal, trackEvent, logger } from '../../../../services'; import './DonationSection.css'; @@ -22,21 +23,25 @@ function DonationSection(): React.JSX.Element {

Enjoying the app?

- + - +
); diff --git a/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx b/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx index 132e6ec..b935599 100644 --- a/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx +++ b/src/renderer/components/layout/LeftPanel/TranscriptionActions/TranscriptionActions.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Zap } from 'lucide-react'; +import { Button } from '../../../../components/ui'; import { useAppTranscription } from '../../../../contexts'; export interface TranscriptionActionsProps { @@ -21,23 +22,29 @@ function TranscriptionActions({ isFFmpegAvailable }: TranscriptionActionsProps): return (
{!isTranscribing ? ( - + Transcribe + ) : ( - + Cancel + )}
); diff --git a/src/renderer/components/ui/Button/Button.css b/src/renderer/components/ui/Button/Button.css new file mode 100644 index 0000000..1b21990 --- /dev/null +++ b/src/renderer/components/ui/Button/Button.css @@ -0,0 +1,267 @@ +.btn { + font-family: inherit; + font-weight: 500; + border-radius: var(--radius-sm); + border: none; + cursor: pointer; + transition: all var(--transition-fast); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + position: relative; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-sm { + padding: 6px 10px; + font-size: 0.8rem; +} + +.btn-md { + padding: 10px 16px; + font-size: 0.9rem; +} + +.btn-lg { + padding: 12px 20px; + font-size: 0.95rem; +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent), var(--accent-hover)); + color: white; + box-shadow: var(--shadow-accent); +} + +.btn-primary::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.2); + pointer-events: none; +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: + 0 0 0 1px var(--accent-border), + 0 4px 16px var(--accent-glow), + 0 8px 32px rgba(37, 99, 235, 0.2); +} + +.btn-primary:active:not(:disabled) { + transform: scale(0.98); + box-shadow: var(--shadow-accent); +} + +.btn-danger { + background: var(--danger); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; +} + +.btn-secondary { + background: var(--surface); + color: var(--text-primary); + border: 1px solid var(--border); + box-shadow: var(--shadow-inner); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--surface-hover); + border-color: var(--border-hover); +} + +.btn-icon { + padding: 8px 12px; + font-size: 0.85rem; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-primary); +} + +.btn-icon:hover:not(:disabled) { + background: var(--surface-hover); + border-color: var(--border-hover); +} + +.btn-icon.btn-active { + background: var(--accent-light); + border-color: var(--accent-border); + color: var(--accent); +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); + border: none; + padding: 6px 8px; +} + +.btn-ghost:hover:not(:disabled) { + background: var(--surface-hover); + color: var(--text-primary); +} + +.btn-icon-only { + padding: 8px; + aspect-ratio: 1; +} + +.btn-icon-only.btn-sm { + padding: 6px; +} + +.btn-icon-only.btn-lg { + padding: 10px; +} + +.btn-full-width { + width: 100%; +} + +.btn-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: white; + border-radius: 50%; + animation: btn-spin 0.8s linear infinite; +} + +.btn-secondary .btn-spinner, +.btn-icon .btn-spinner, +.btn-ghost .btn-spinner { + border-color: var(--border); + border-top-color: var(--accent); +} + +.btn-icon-wrapper { + display: flex; + align-items: center; + justify-content: center; +} + +.btn-text { + display: inline-flex; + align-items: center; +} + +.btn-icon.danger { + color: var(--danger); +} + +.btn-icon.danger:hover:not(:disabled) { + background: var(--danger-light); + border-color: var(--danger); +} + +.btn-secondary.danger, +.btn.danger { + background: transparent; + color: var(--danger); + border: 1px solid var(--danger); +} + +.btn-secondary.danger:hover:not(:disabled), +.btn.danger:hover:not(:disabled) { + background: var(--danger-light); + border-color: var(--danger); + color: var(--danger); +} + +.btn.success, +.btn-icon.success, +.btn.btn-active.success { + background: var(--success-light); + border-color: var(--success); + color: var(--success); +} + +.btn.success:hover:not(:disabled), +.btn-icon.success:hover:not(:disabled) { + background: var(--success); + color: white; + border-color: var(--success); +} + +.btn.copied, +.btn-primary.copied, +.btn-secondary.copied { + background: var(--success); + color: white; + border-color: var(--success); +} + +.btn.copied:hover:not(:disabled), +.btn-primary.copied:hover:not(:disabled), +.btn-secondary.copied:hover:not(:disabled) { + background: var(--success); + color: white; +} + +.btn.warning, +.btn-warning { + background: var(--warning); + border: none; + color: white; +} + +.btn.warning:hover:not(:disabled), +.btn-warning:hover:not(:disabled) { + background: var(--warning-dark); + transform: translateY(-1px); +} + +.btn.warning:active:not(:disabled), +.btn-warning:active:not(:disabled) { + transform: translateY(0); +} + +.btn.accent { + background: var(--accent-light); + border: 1px solid var(--accent-border); + color: var(--accent); +} + +.btn.accent:hover:not(:disabled) { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.btn-link, +.btn.link { + background: none; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + color: var(--accent); + text-decoration: none; + font-weight: 500; +} + +.btn-link:hover, +.btn.link:hover { + text-decoration: underline; +} + +@keyframes btn-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/src/renderer/components/ui/Button/Button.tsx b/src/renderer/components/ui/Button/Button.tsx new file mode 100644 index 0000000..b96c36f --- /dev/null +++ b/src/renderer/components/ui/Button/Button.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import './Button.css'; + +interface BaseButtonProps { + variant?: 'primary' | 'danger' | 'secondary' | 'icon' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + icon?: React.ReactNode; + loading?: boolean; + active?: boolean; + fullWidth?: boolean; +} + +type IconOnlyButtonProps = BaseButtonProps & + Omit, 'aria-label' | 'children'> & { + iconOnly: true; + 'aria-label': string; + children?: never; + }; + +type StandardButtonProps = BaseButtonProps & + Omit, 'aria-label'> & { + iconOnly?: false; + 'aria-label'?: string; + }; + +export type ButtonProps = IconOnlyButtonProps | StandardButtonProps; + +function Button({ + variant = 'secondary', + size = 'md', + icon, + iconOnly = false, + loading = false, + active = false, + fullWidth = false, + disabled, + className = '', + children, + ...props +}: ButtonProps): React.JSX.Element { + const classNames = [ + 'btn', + `btn-${variant}`, + `btn-${size}`, + iconOnly && 'btn-icon-only', + loading && 'btn-loading', + active && 'btn-active', + fullWidth && 'btn-full-width', + className, + ] + .filter(Boolean) + .join(' '); + + return ( + + ); +} + +export { Button }; diff --git a/src/renderer/components/ui/Button/__tests__/Button.test.tsx b/src/renderer/components/ui/Button/__tests__/Button.test.tsx new file mode 100644 index 0000000..b95fb26 --- /dev/null +++ b/src/renderer/components/ui/Button/__tests__/Button.test.tsx @@ -0,0 +1,157 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Search, Copy, Trash2 } from 'lucide-react'; +import { Button } from '../Button'; + +describe('Button', () => { + it('renders with default props', () => { + render(); + + const button = screen.getByRole('button', { name: /click me/i }); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass('btn', 'btn-secondary', 'btn-md'); + }); + + it('renders with primary variant', () => { + render(); + + expect(screen.getByRole('button')).toHaveClass('btn-primary'); + }); + + it('renders with danger variant', () => { + render(); + + expect(screen.getByRole('button')).toHaveClass('btn-danger'); + }); + + it('renders with icon variant', () => { + render(); + + expect(screen.getByRole('button')).toHaveClass('btn-icon'); + }); + + it('renders with ghost variant', () => { + render(); + + expect(screen.getByRole('button')).toHaveClass('btn-ghost'); + }); + + it('renders with different sizes', () => { + const { rerender } = render(); + expect(screen.getByRole('button')).toHaveClass('btn-sm'); + + rerender(); + expect(screen.getByRole('button')).toHaveClass('btn-md'); + + rerender(); + expect(screen.getByRole('button')).toHaveClass('btn-lg'); + }); + + it('renders with an icon', () => { + render(); + + expect(screen.getByTestId('search-icon')).toBeInTheDocument(); + expect(screen.getByText('Search')).toBeInTheDocument(); + }); + + it('renders icon-only button', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('btn-loading'); + expect(button).toBeDisabled(); + expect(button.querySelector('.btn-spinner')).toBeInTheDocument(); + }); + + it('hides icon when loading', () => { + render( + + ); + + expect(screen.queryByTestId('search-icon')).not.toBeInTheDocument(); + expect(screen.getByRole('button').querySelector('.btn-spinner')).toBeInTheDocument(); + }); + + it('applies active class when active', () => { + render(); + + expect(screen.getByRole('button')).toHaveClass('btn-active'); + }); + + it('applies full-width class', () => { + render(); + + expect(screen.getByRole('button')).toHaveClass('btn-full-width'); + }); + + it('handles click events', () => { + const handleClick = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('does not trigger click when disabled', () => { + const handleClick = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button')); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it('does not trigger click when loading', () => { + const handleClick = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button')); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it('passes through additional button attributes', () => { + render( + + ); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('type', 'submit'); + expect(button).toHaveAttribute('title', 'Submit form'); + }); + + it('merges custom className with default classes', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('btn', 'btn-secondary', 'custom-class'); + }); + + it('can be used with danger modifier class for icon variant', () => { + render( + + ); + + expect(screen.getByRole('button')).toHaveClass('btn-icon', 'danger'); + }); +}); diff --git a/src/renderer/components/ui/Button/index.ts b/src/renderer/components/ui/Button/index.ts new file mode 100644 index 0000000..fa3c8a5 --- /dev/null +++ b/src/renderer/components/ui/Button/index.ts @@ -0,0 +1,2 @@ +export { Button } from './Button'; +export type { ButtonProps } from './Button'; diff --git a/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.css b/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.css index 407ae2e..338f79e 100644 --- a/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.css +++ b/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.css @@ -123,58 +123,14 @@ background-color: var(--bg-secondary); } -.debug-logs-footer button { +.debug-logs-footer .btn { flex: 1; - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 10px 16px; - border-radius: var(--radius-sm); - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: all var(--transition-base); } -.btn-copy-logs { - background-color: var(--accent); - color: white; - border: none; -} - -.btn-copy-logs:hover { - background-color: var(--accent-hover); -} - -.btn-copy-with-info { - background-color: var(--bg-tertiary); - color: var(--text-primary); - border: 1px solid var(--border); -} - -.btn-copy-with-info:hover { - background-color: var(--bg-hover); -} - -.btn-clear-logs { - background-color: transparent; - color: var(--danger); - border: 1px solid var(--danger); +.debug-logs-footer .btn-clear-logs { flex: 0.5; } -.btn-clear-logs:hover { - background-color: var(--danger-light); -} - -.btn-copy-logs.copied, -.btn-copy-with-info.copied { - background-color: var(--success); - color: white; - border-color: transparent; -} - @keyframes fadeIn { from { opacity: 0; diff --git a/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.tsx b/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.tsx index 9036d9a..d0cb517 100644 --- a/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.tsx +++ b/src/renderer/components/ui/DebugLogsModal/DebugLogsModal.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useCallback } from 'react'; import { X, Terminal, Copy, Clipboard, Trash2 } from 'lucide-react'; +import { Button } from '../Button'; import type { LogEntry } from '../../../services/logger'; import './DebugLogsModal.css'; @@ -88,9 +89,14 @@ function DebugLogsModal({ Debug Logs ({logs.length} entries) - + - - + + diff --git a/src/renderer/components/ui/DebugLogsModal/__tests__/DebugLogsModal.test.tsx b/src/renderer/components/ui/DebugLogsModal/__tests__/DebugLogsModal.test.tsx new file mode 100644 index 0000000..12db68b --- /dev/null +++ b/src/renderer/components/ui/DebugLogsModal/__tests__/DebugLogsModal.test.tsx @@ -0,0 +1,380 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { DebugLogsModal } from '../DebugLogsModal'; +import type { LogEntry } from '../../../../services/logger'; + +describe('DebugLogsModal', () => { + const mockLogs: LogEntry[] = [ + { + timestamp: new Date('2024-01-01T12:00:00.123Z'), + level: 'info', + message: 'Test info message', + }, + { + timestamp: new Date('2024-01-01T12:00:01.456Z'), + level: 'warn', + message: 'Test warning message', + data: { key: 'value' }, + }, + { + timestamp: new Date('2024-01-01T12:00:02.789Z'), + level: 'error', + message: 'Test error message', + data: { error: 'details' }, + }, + ]; + + const mockOnClose = vi.fn(); + const mockOnCopyLogs = vi.fn(); + const mockOnCopyLogsWithSystemInfo = vi.fn(); + const mockOnClearLogs = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllTimers(); + }); + + it('renders nothing when not open', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('renders modal when open', () => { + render( + + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Debug Logs')).toBeInTheDocument(); + expect(screen.getByText(/3 entries/i)).toBeInTheDocument(); + }); + + it('displays logs with correct formatting', () => { + render( + + ); + + expect(screen.getByText(/Test info message/)).toBeInTheDocument(); + expect(screen.getByText(/Test warning message/)).toBeInTheDocument(); + expect(screen.getByText(/Test error message/)).toBeInTheDocument(); + }); + + it('displays empty state when no logs', () => { + render( + + ); + + expect(screen.getByText('No logs captured yet.')).toBeInTheDocument(); + expect( + screen.getByText('Logs will appear here as you use the application.') + ).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', () => { + render( + + ); + + const closeButton = screen.getByLabelText('Close debug logs'); + fireEvent.click(closeButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when Escape key is pressed', () => { + render( + + ); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when overlay is clicked', () => { + render( + + ); + + const overlay = screen.getByRole('dialog').parentElement; + if (overlay) { + fireEvent.click(overlay); + expect(mockOnClose).toHaveBeenCalledTimes(1); + } + }); + + it('does not call onClose when modal content is clicked', () => { + render( + + ); + + const modal = screen.getByRole('dialog'); + fireEvent.click(modal); + + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it('calls onCopyLogs when copy logs button is clicked', async () => { + mockOnCopyLogs.mockResolvedValue(true); + + render( + + ); + + const copyButton = screen.getByRole('button', { name: /copy logs/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(mockOnCopyLogs).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(screen.getByText('Copied!')).toBeInTheDocument(); + }); + }); + + it('calls onCopyLogsWithSystemInfo when button is clicked', async () => { + mockOnCopyLogsWithSystemInfo.mockResolvedValue(true); + + render( + + ); + + const copyWithInfoButton = screen.getByRole('button', { name: /copy with system info/i }); + fireEvent.click(copyWithInfoButton); + + await waitFor(() => { + expect(mockOnCopyLogsWithSystemInfo).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(screen.getByText('Copied!')).toBeInTheDocument(); + }); + }); + + it('calls onClearLogs when clear button is clicked', () => { + render( + + ); + + const clearButton = screen.getByRole('button', { name: /clear/i }); + fireEvent.click(clearButton); + + expect(mockOnClearLogs).toHaveBeenCalledTimes(1); + }); + + it('disables copy and clear buttons when no logs', () => { + render( + + ); + + const copyLogsButton = screen.getByRole('button', { name: /copy logs/i }); + const copyWithInfoButton = screen.getByRole('button', { name: /copy with system info/i }); + const clearButton = screen.getByRole('button', { name: /clear/i }); + + expect(copyLogsButton).toBeDisabled(); + expect(copyWithInfoButton).toBeDisabled(); + expect(clearButton).toBeDisabled(); + }); + + it('does not show copied state when copy fails', async () => { + mockOnCopyLogs.mockResolvedValue(false); + + render( + + ); + + const copyButton = screen.getByRole('button', { name: /copy logs/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(mockOnCopyLogs).toHaveBeenCalledTimes(1); + }); + + expect(screen.queryByText('Copied!')).not.toBeInTheDocument(); + expect(screen.getByText('Copy Logs')).toBeInTheDocument(); + }, 10000); + + it('formats log entries correctly', () => { + const logWithData: LogEntry = { + timestamp: new Date('2024-01-01T12:34:56.789Z'), + level: 'debug', + message: 'Debug message', + data: { test: 'data' }, + }; + + const logWithoutData: LogEntry = { + timestamp: new Date('2024-01-01T12:34:56.789Z'), + level: 'info', + message: 'Info message', + }; + + render( + + ); + + expect(screen.getByText(/Debug message/)).toBeInTheDocument(); + expect(screen.getByText(/Info message/)).toBeInTheDocument(); + }); + + it('cleans up event listener when modal is closed', () => { + const { rerender } = render( + + ); + + rerender( + + ); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it('adds keydown listener only when modal is open', () => { + const { rerender } = render( + + ); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(mockOnClose).not.toHaveBeenCalled(); + + rerender( + + ); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.css b/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.css index 63ad10f..681661f 100644 --- a/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.css +++ b/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.css @@ -53,35 +53,3 @@ justify-content: center; flex-wrap: wrap; } - -.error-boundary-btn { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.25rem; - border-radius: 8px; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - border: none; -} - -.error-boundary-btn-primary { - background: var(--color-primary, #6366f1); - color: white; -} - -.error-boundary-btn-primary:hover { - background: var(--color-primary-hover, #5558e3); -} - -.error-boundary-btn-secondary { - background: var(--bg-secondary, #252540); - color: var(--text-primary, #e0e0e0); - border: 1px solid var(--border-color, #3a3a5c); -} - -.error-boundary-btn-secondary:hover { - background: var(--bg-tertiary, #2d2d4a); -} diff --git a/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx b/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx index 14409ef..34515b8 100644 --- a/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx +++ b/src/renderer/components/ui/ErrorBoundary/ErrorBoundary.tsx @@ -1,5 +1,6 @@ import React, { type ReactNode } from 'react'; import { AlertTriangle, RefreshCw } from 'lucide-react'; +import { Button } from '../Button'; import { logger } from '../../../services/logger'; import './ErrorBoundary.css'; @@ -57,19 +58,12 @@ class ErrorBoundary extends React.Component{this.state.error.message} )}
- - + +
diff --git a/src/renderer/components/ui/ProgressBar/__tests__/ProgressBar.test.tsx b/src/renderer/components/ui/ProgressBar/__tests__/ProgressBar.test.tsx new file mode 100644 index 0000000..00e6263 --- /dev/null +++ b/src/renderer/components/ui/ProgressBar/__tests__/ProgressBar.test.tsx @@ -0,0 +1,254 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ProgressBar } from '../ProgressBar'; + +describe('ProgressBar', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders with basic props', () => { + render(); + + const progressBar = screen.getByRole('progressbar'); + expect(progressBar).toBeInTheDocument(); + expect(progressBar).toHaveAttribute('aria-valuenow', '50'); + expect(progressBar).toHaveAttribute('aria-valuemin', '0'); + expect(progressBar).toHaveAttribute('aria-valuemax', '100'); + }); + + it('displays status text', () => { + render( + + ); + + expect(screen.getByText('Analyzing audio...')).toBeInTheDocument(); + }); + + it('shows elapsed time when active with startTime', () => { + const startTime = Date.now() - 5000; // 5 seconds ago + + render( + + ); + + // Check that elapsed time element is rendered (even if timing might vary slightly) + const progressInfo = document.querySelector('.progress-info'); + expect(progressInfo).toBeInTheDocument(); + }); + + it('updates elapsed time every second', () => { + const startTime = Date.now(); + + render( + + ); + + // Check that the component renders and updates + const progressInfo = document.querySelector('.progress-info'); + expect(progressInfo).toBeInTheDocument(); + }); + + it('does not show elapsed time when not active', () => { + const startTime = Date.now() - 10000; + + render(); + + expect(screen.queryByText(/0:/)).not.toBeInTheDocument(); + }); + + it('does not show elapsed time when startTime is null', () => { + render(); + + expect(screen.queryByText(/0:/)).not.toBeInTheDocument(); + }); + + it('resets elapsed time when becomes inactive', () => { + const startTime = Date.now() - 5000; + const { rerender } = render( + + ); + + rerender( + + ); + + expect(screen.queryByText(/0:/)).not.toBeInTheDocument(); + }); + + it('shows indeterminate progress for mid-range percentages (15-85%)', () => { + const { container } = render( + + ); + + const progressBar = container.querySelector('.progress-bar'); + expect(progressBar).toHaveClass('indeterminate'); + }); + + it('shows determinate progress for low percentages (< 15%)', () => { + const { container } = render( + + ); + + const progressBar = container.querySelector('.progress-bar'); + expect(progressBar).not.toHaveClass('indeterminate'); + + const progressFill = container.querySelector('.progress-fill'); + expect(progressFill).toHaveStyle({ width: '10%' }); + }); + + it('shows determinate progress for high percentages (> 85%)', () => { + const { container } = render( + + ); + + const progressBar = container.querySelector('.progress-bar'); + expect(progressBar).not.toHaveClass('indeterminate'); + + const progressFill = container.querySelector('.progress-fill'); + expect(progressFill).toHaveStyle({ width: '90%' }); + }); + + it('shows indeterminate progress at exactly 15%', () => { + const { container } = render( + + ); + + const progressBar = container.querySelector('.progress-bar'); + expect(progressBar).toHaveClass('indeterminate'); + }); + + it('shows indeterminate progress at exactly 85%', () => { + const { container } = render( + + ); + + const progressBar = container.querySelector('.progress-bar'); + expect(progressBar).toHaveClass('indeterminate'); + }); + + it('shows determinate progress when not active', () => { + const { container } = render( + + ); + + const progressBar = container.querySelector('.progress-bar'); + expect(progressBar).not.toHaveClass('indeterminate'); + + const progressFill = container.querySelector('.progress-fill'); + expect(progressFill).toHaveStyle({ width: '50%' }); + }); + + it('uses 30% width for indeterminate progress', () => { + const { container } = render( + + ); + + const progressFill = container.querySelector('.progress-fill'); + expect(progressFill).toHaveStyle({ width: '30%' }); + }); + + it('handles 0% progress', () => { + const { container } = render( + + ); + + const progressFill = container.querySelector('.progress-fill'); + expect(progressFill).toHaveStyle({ width: '0%' }); + }); + + it('handles 100% progress', () => { + const { container } = render( + + ); + + const progressFill = container.querySelector('.progress-fill'); + expect(progressFill).toHaveStyle({ width: '100%' }); + }); + + it('has correct accessibility attributes', () => { + render(); + + const progressBar = screen.getByRole('progressbar'); + expect(progressBar).toHaveAttribute('aria-label', 'Transcription progress: 75%'); + }); + + it('updates aria-label when percent changes', () => { + const { rerender } = render( + + ); + + let progressBar = screen.getByRole('progressbar'); + expect(progressBar).toHaveAttribute('aria-label', 'Transcription progress: 25%'); + + rerender(); + + progressBar = screen.getByRole('progressbar'); + expect(progressBar).toHaveAttribute('aria-label', 'Transcription progress: 75%'); + }); + + it('updates status with aria-live region', () => { + render( + + ); + + const statusElement = screen.getByText('Processing audio...'); + expect(statusElement).toHaveAttribute('aria-live', 'polite'); + }); + + it('clears interval when component unmounts', () => { + const startTime = Date.now(); + const { unmount } = render( + + ); + + unmount(); + + // Advance time - should not cause any errors + vi.advanceTimersByTime(5000); + }); + + it('clears and recreates interval when startTime changes', () => { + const startTime1 = Date.now() - 3000; + const { rerender } = render( + + ); + + const startTime2 = Date.now(); + rerender( + + ); + + // Component should update properly + const progressBar = screen.getByRole('progressbar'); + expect(progressBar).toHaveAttribute('aria-valuenow', '60'); + }); + + it('renders empty status correctly', () => { + render(); + + const progressBar = screen.getByRole('progressbar'); + expect(progressBar).toBeInTheDocument(); + }); + + it('handles rapid state changes', () => { + const { rerender } = render( + + ); + + rerender( + + ); + rerender( + + ); + rerender(); + + const progressBar = screen.getByRole('progressbar'); + expect(progressBar).toHaveAttribute('aria-valuenow', '100'); + }); +}); diff --git a/src/renderer/components/ui/SystemWarning/SystemWarning.css b/src/renderer/components/ui/SystemWarning/SystemWarning.css index de8f087..6c93630 100644 --- a/src/renderer/components/ui/SystemWarning/SystemWarning.css +++ b/src/renderer/components/ui/SystemWarning/SystemWarning.css @@ -74,25 +74,6 @@ box-shadow: var(--shadow-inner); } -.copy-button { - background: transparent; - border: 1px solid transparent; - cursor: pointer; - color: var(--text-muted); - padding: 6px; - border-radius: var(--radius-sm); - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; -} - -.copy-button:hover { - color: var(--text-primary); - background-color: var(--bg-secondary); - border-color: var(--border); -} - .alternative-option { font-size: 0.85rem; color: var(--text-muted); @@ -103,66 +84,3 @@ gap: 4px; align-items: center; } - -.link-button { - background: none; - border: none; - padding: 0; - font: inherit; - cursor: pointer; - color: var(--accent); - text-decoration: none; - font-weight: 500; -} - -.link-button:hover { - text-decoration: underline; -} - -.refresh-button { - background: var(--warning); - border: none; - color: white; - padding: 10px 16px; - border-radius: var(--radius-sm); - font-size: 0.95rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - width: 100%; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; -} - -.refresh-button:hover:not(:disabled) { - background: var(--warning-dark); - transform: translateY(-1px); -} - -.refresh-button:focus-visible:not(:disabled) { - transform: translateY(-1px); -} - -.refresh-button:active:not(:disabled) { - transform: translateY(0); -} - -.refresh-button:disabled { - opacity: 0.7; - cursor: not-allowed; -} - -.spin { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/src/renderer/components/ui/SystemWarning/SystemWarning.tsx b/src/renderer/components/ui/SystemWarning/SystemWarning.tsx index 6669ba2..407545e 100644 --- a/src/renderer/components/ui/SystemWarning/SystemWarning.tsx +++ b/src/renderer/components/ui/SystemWarning/SystemWarning.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { Copy, Check, AlertTriangle, RefreshCw } from 'lucide-react'; +import { Button } from '../Button'; import './SystemWarning.css'; import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; import { trackEvent, openExternal, getAppInfo, logger } from '../../../services'; @@ -107,43 +108,41 @@ function SystemWarning({ onRefresh }: SystemWarningProps): React.JSX.Element {

Run this command in your terminal:

{installCommand} - + />
Or download manually from - +
- + ); } diff --git a/src/renderer/components/ui/index.ts b/src/renderer/components/ui/index.ts index f450df6..1f7410e 100644 --- a/src/renderer/components/ui/index.ts +++ b/src/renderer/components/ui/index.ts @@ -1,3 +1,5 @@ +export { Button } from './Button'; +export type { ButtonProps } from './Button'; export { ErrorBoundary } from './ErrorBoundary'; export type { ErrorBoundaryProps } from './ErrorBoundary'; export { ProgressBar } from './ProgressBar'; diff --git a/src/renderer/config/__tests__/constants.test.ts b/src/renderer/config/__tests__/constants.test.ts index cf7e033..d65b2b6 100644 --- a/src/renderer/config/__tests__/constants.test.ts +++ b/src/renderer/config/__tests__/constants.test.ts @@ -8,6 +8,7 @@ import { getLanguageLabel, APP_CONFIG, } from '../../config/constants'; +import type { LanguageCode, QualityLevel } from '../../types'; describe('config/constants', () => { describe('SUPPORTED_EXTENSIONS', () => { @@ -95,15 +96,15 @@ describe('config/constants', () => { }); it('handles out of range values', () => { - const tooLow = getQualityStars(0 as unknown as 1); - const tooHigh = getQualityStars(10 as unknown as 1); + const tooLow = getQualityStars(0 as QualityLevel); + const tooHigh = getQualityStars(10 as QualityLevel); expect(tooLow).toBe('★☆☆☆☆'); expect(tooHigh).toBe('★★★★★'); }); it('clamps negative values', () => { - const result = getQualityStars(-5 as unknown as 1); + const result = getQualityStars(-5 as QualityLevel); expect(result).toBe('★☆☆☆☆'); }); }); @@ -136,5 +137,15 @@ describe('config/constants', () => { expect(label).toBe(lang.label); }); }); + + it('returns the code itself for unknown language code', () => { + const unknownCode = 'xx' as LanguageCode; + expect(getLanguageLabel(unknownCode)).toBe('xx'); + }); + + it('handles edge cases with unknown codes', () => { + expect(getLanguageLabel('unknown' as LanguageCode)).toBe('unknown'); + expect(getLanguageLabel('test-lang' as LanguageCode)).toBe('test-lang'); + }); }); }); diff --git a/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.css b/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.css index 25901c6..5db06a3 100644 --- a/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.css +++ b/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.css @@ -48,6 +48,13 @@ } } +.update-close-btn { + position: absolute; + top: 16px; + right: 16px; + z-index: 1; +} + .update-available, .update-downloading, .update-downloaded, @@ -141,44 +148,6 @@ margin-top: 8px; } -.btn-download, -.btn-install { - flex: 1; - padding: 12px 24px; - background: var(--accent); - color: white; - border: none; - border-radius: var(--radius-sm); - font-size: 1rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; -} - -.btn-download:hover, -.btn-install:hover { - background: var(--accent-hover); - transform: translateY(-1px); - box-shadow: var(--shadow); -} - -.btn-dismiss { - padding: 12px 24px; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - font-size: 1rem; - cursor: pointer; - transition: all 0.2s ease; -} - -.btn-dismiss:hover { - background: var(--bg-hover); - border-color: var(--border-hover); - color: var(--text-primary); -} - .progress-bar { width: 100%; height: 6px; @@ -208,30 +177,6 @@ color: #ef4444; } -.btn-close { - position: absolute; - top: 16px; - right: 16px; - width: 32px; - height: 32px; - border-radius: var(--radius-sm); - border: none; - background: transparent; - color: var(--text-muted); - font-size: 20px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease; - z-index: 1; -} - -.btn-close:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - @media (max-width: 768px) { .update-notification-content { width: 95%; diff --git a/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.tsx b/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.tsx index 7533701..02f8d5a 100644 --- a/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.tsx +++ b/src/renderer/features/auto-update/components/UpdateNotification/UpdateNotification.tsx @@ -1,5 +1,6 @@ import { useAutoUpdate } from '../../hooks/useAutoUpdate'; import { convertHtmlToText, formatFileSize } from '../../../../utils'; +import { Button } from '../../../../components/ui'; import { X, AlertTriangle, Sparkles, Download, CheckCircle } from 'lucide-react'; import './UpdateNotification.css'; @@ -30,9 +31,14 @@ function UpdateNotification() { return (
e.stopPropagation()}> - +
- +
)} @@ -75,12 +82,12 @@ function UpdateNotification() { )}
- - + +
)} @@ -126,12 +133,12 @@ function UpdateNotification() {
- - + +
)} diff --git a/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.css b/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.css index 393df48..eb07318 100644 --- a/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.css +++ b/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.css @@ -35,29 +35,6 @@ gap: 8px; } -.history-actions button { - padding: 6px 10px; - font-size: 0.8rem; - background: var(--surface); - border: 1px solid var(--border); - color: var(--text-primary); - border-radius: var(--radius-sm); - transition: all var(--transition-fast); -} - -.history-actions button:hover { - background: var(--surface-hover); -} - -.history-actions .danger { - color: var(--danger); - border-color: var(--danger); -} - -.history-actions .danger:hover { - background: var(--danger-light); -} - .history-content { flex: 1; padding: 12px; diff --git a/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx b/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx index b82ab2a..374af92 100644 --- a/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx +++ b/src/renderer/features/history/components/TranscriptionHistory/TranscriptionHistory.tsx @@ -1,5 +1,6 @@ import React, { type KeyboardEvent } from 'react'; import { History, Trash2, X, Inbox, Clock } from 'lucide-react'; +import { Button } from '../../../../components/ui'; import { formatDate, formatDuration } from '../../../../utils'; import { getLanguageLabel } from '../../../../config'; import './TranscriptionHistory.css'; @@ -48,13 +49,18 @@ function TranscriptionHistory({
{history.length > 0 && ( - + )} - +
@@ -82,14 +88,16 @@ function TranscriptionHistory({ {item.fileName}
{formatDate(item.date)} - + className="history-item-delete" + />
diff --git a/src/renderer/features/settings/components/ModelDetails/ModelDetails.css b/src/renderer/features/settings/components/ModelDetails/ModelDetails.css index ef019fd..14117dc 100644 --- a/src/renderer/features/settings/components/ModelDetails/ModelDetails.css +++ b/src/renderer/features/settings/components/ModelDetails/ModelDetails.css @@ -38,24 +38,6 @@ margin-top: 10px; } -.btn-download { - width: 100%; - padding: 8px 12px; - font-size: 0.85rem; - background: var(--accent-light); - border: 1px solid var(--accent-border); - color: var(--accent); - border-radius: var(--radius-sm); - cursor: pointer; - transition: all var(--transition-fast); -} - -.btn-download:hover { - background: var(--accent); - color: white; - border-color: var(--accent); -} - .downloading { display: flex; align-items: center; @@ -107,26 +89,3 @@ justify-content: space-between; margin-top: 8px; } - -.btn-delete-model { - background: transparent; - border: none; - cursor: pointer; - padding: 4px; - border-radius: var(--radius-sm); - transition: all var(--transition-fast); - font-size: 1rem; - opacity: 0.6; - color: var(--text-muted); -} - -.btn-delete-model:hover { - background: var(--danger-light); - opacity: 1; - color: var(--danger); -} - -.btn-delete-model:disabled { - opacity: 0.3; - cursor: not-allowed; -} diff --git a/src/renderer/features/settings/components/ModelDetails/ModelDetails.tsx b/src/renderer/features/settings/components/ModelDetails/ModelDetails.tsx index 65da7f2..99d2096 100644 --- a/src/renderer/features/settings/components/ModelDetails/ModelDetails.tsx +++ b/src/renderer/features/settings/components/ModelDetails/ModelDetails.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Download, Check, Trash2 } from 'lucide-react'; +import { Button } from '../../../../components/ui'; import './ModelDetails.css'; import type { ModelInfo, ModelDownloadProgress } from '../../../../types'; import { QUALITY_STARS } from '../../../../config'; @@ -53,14 +54,17 @@ function ModelDetails({ )}
) : ( - + Download {model.size} + )} )} @@ -70,15 +74,17 @@ function ModelDetails({
- + className="danger" + /> )} diff --git a/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx b/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx index a781638..029c2f0 100644 --- a/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx +++ b/src/renderer/features/transcription/components/FileDropZone/FileDropZone.tsx @@ -1,5 +1,6 @@ import React, { useCallback, type DragEvent, type KeyboardEvent } from 'react'; import { X } from 'lucide-react'; +import { Button } from '../../../../components/ui'; import { isValidMediaFile, formatFileSize } from '../../../../utils'; import type { SelectedFile } from '../../../../types'; import { openFileDialog, getPathForFile } from '../../../../services/electronAPI'; @@ -79,14 +80,16 @@ function FileDropZone({ > {selectedFile ? (
- + className="file-remove" + /> 📁
{selectedFile.name} diff --git a/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.css b/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.css index 135a9da..fd5e4a0 100644 --- a/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.css +++ b/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.css @@ -39,35 +39,3 @@ padding: 0 8px; white-space: nowrap; } - -.search-nav-btn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - font-size: 0.85rem; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-primary); - cursor: pointer; - transition: all var(--transition-fast); -} - -.search-nav-btn:hover:not(:disabled) { - background: var(--surface-hover); - border-color: var(--border-hover); -} - -.search-nav-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.search-nav-btn.close:hover { - background: var(--danger-light); - border-color: var(--danger); - color: var(--danger); -} diff --git a/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.tsx b/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.tsx index eb47ab9..1aa90c9 100644 --- a/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.tsx +++ b/src/renderer/features/transcription/components/TranscriptionSearch/TranscriptionSearch.tsx @@ -1,5 +1,6 @@ import React, { useRef, useEffect, type ChangeEvent } from 'react'; import { ChevronUp, ChevronDown, X } from 'lucide-react'; +import { Button } from '../../../../components/ui'; import './TranscriptionSearch.css'; export interface TranscriptionSearchProps { @@ -50,32 +51,35 @@ function TranscriptionSearch({ {totalMatches > 0 ? `${currentMatchIndex + 1} of ${totalMatches}` : 'No matches'} )} - - - + />
); diff --git a/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css index c2d10a4..642a441 100644 --- a/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css +++ b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.css @@ -30,27 +30,6 @@ gap: 8px; } -.output-actions .btn-icon { - padding: 6px 12px; - font-size: 0.8rem; - background: var(--surface); - border: 1px solid var(--border); - color: var(--text-primary); - border-radius: var(--radius-sm); - transition: all var(--transition-fast); -} - -.output-actions .btn-icon:hover { - background: var(--surface-hover); - border-color: var(--border-hover); -} - -.output-actions .btn-icon.success { - background: var(--success-light); - border-color: var(--success); - color: var(--success); -} - .save-dropdown { position: relative; } @@ -91,9 +70,3 @@ color: var(--text-muted); font-size: 0.75rem; } - -.output-actions .btn-icon.active { - background: var(--accent-light); - border-color: var(--accent); - color: var(--accent); -} diff --git a/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx index 41a23cc..8ad130a 100644 --- a/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx +++ b/src/renderer/features/transcription/components/TranscriptionToolbar/TranscriptionToolbar.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; import { Search, Check, Copy, Save } from 'lucide-react'; +import { Button } from '../../../../components/ui'; import './TranscriptionToolbar.css'; import type { OutputFormat } from '../../../../types'; import { OUTPUT_FORMATS } from '../../../../config'; @@ -58,40 +59,37 @@ function TranscriptionToolbar({ {hasText && (
- - + {copySuccess ? 'Copied!' : 'Copy'} +
- + Save + {showSaveMenu && (
{OUTPUT_FORMATS.map((format) => ( diff --git a/src/renderer/index.css b/src/renderer/index.css index 78eff66..45b03de 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -171,3 +171,16 @@ body { transition-duration: 150ms; } } + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +}