2026-05-05 12:29:25 -06:00
import { useEffect , useState , useMemo , useCallback , useRef } from 'react' ;
2026-03-03 06:31:04 -05:00
import { useEditorStore } from './store/editorStore' ;
import VideoPlayer from './components/VideoPlayer' ;
import TranscriptEditor from './components/TranscriptEditor' ;
import WaveformTimeline from './components/WaveformTimeline' ;
import AIPanel from './components/AIPanel' ;
import ExportDialog from './components/ExportDialog' ;
import SettingsPanel from './components/SettingsPanel' ;
2026-03-30 18:36:41 -06:00
import DevPanel from './components/DevPanel' ;
2026-05-05 10:22:35 -06:00
import MarkersPanel from './components/MarkersPanel' ;
2026-04-03 12:05:44 -06:00
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel' ;
2026-04-15 18:00:34 -06:00
import ZoneEditor from './components/ZoneEditor' ;
2026-05-05 20:46:55 -06:00
import BackgroundMusicPanel from './components/BackgroundMusicPanel' ;
import AppendClipPanel from './components/AppendClipPanel' ;
2026-05-06 01:35:42 -06:00
import LicenseDialog from './components/LicenseDialog' ;
2026-03-03 06:31:04 -05:00
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' ;
2026-05-06 01:35:42 -06:00
import { useLicenseStore } from './store/licenseStore' ;
2026-03-03 06:31:04 -05:00
import {
Film ,
FolderOpen ,
Settings ,
Sparkles ,
Download ,
FileInput ,
2026-03-30 18:36:41 -06:00
Save ,
2026-04-03 11:14:31 -06:00
Scissors ,
VolumeX ,
2026-04-15 16:36:21 -06:00
SlidersHorizontal ,
2026-04-15 19:54:39 -06:00
Gauge ,
2026-04-11 19:42:30 -06:00
FilePlus2 ,
RefreshCw ,
2026-04-15 18:00:34 -06:00
Grid3x3 ,
2026-05-05 10:22:35 -06:00
MapPin ,
2026-05-05 20:46:55 -06:00
Music ,
ListVideo ,
2026-05-06 01:43:55 -06:00
Clock ,
AlertTriangle ,
2026-03-03 06:31:04 -05:00
} from 'lucide-react' ;
2026-04-09 01:36:28 -06:00
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath' ;
2026-03-03 06:31:04 -05:00
2026-05-05 20:46:55 -06:00
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | 'music' | 'append' | null ;
2026-03-03 06:31:04 -05:00
export default function App() {
const {
2026-04-15 20:51:24 -06:00
projectFilePath ,
2026-03-03 06:31:04 -05:00
videoPath ,
2026-04-15 16:10:35 -06:00
exportedAudioPath ,
2026-03-03 06:31:04 -05:00
words ,
2026-04-15 16:10:35 -06:00
segments ,
cutRanges ,
muteRanges ,
gainRanges ,
2026-04-15 19:54:39 -06:00
speedRanges ,
2026-04-15 16:10:35 -06:00
globalGainDb ,
silenceTrimGroups ,
2026-04-11 19:42:30 -06:00
transcriptionModel ,
2026-04-15 16:10:35 -06:00
language ,
2026-03-03 06:31:04 -05:00
isTranscribing ,
2026-03-26 00:58:57 -06:00
transcriptionStatus ,
2026-04-15 21:25:47 -06:00
markInTime ,
markOutTime ,
2026-03-03 06:31:04 -05:00
loadVideo ,
2026-04-15 20:51:24 -06:00
setProjectFilePath ,
2026-03-03 06:31:04 -05:00
setBackendUrl ,
2026-04-15 21:25:47 -06:00
clearMarkRange ,
2026-03-03 06:31:04 -05:00
setTranscription ,
2026-04-11 19:42:30 -06:00
setTranscriptionModel ,
2026-03-03 06:31:04 -05:00
setTranscribing ,
2026-04-03 11:14:31 -06:00
selectedWordIndices ,
addCutRange ,
addMuteRange ,
2026-04-15 16:36:21 -06:00
addGainRange ,
2026-04-15 19:54:39 -06:00
addSpeedRange ,
2026-03-03 06:31:04 -05:00
} = useEditorStore ( ) ;
const [ activePanel , setActivePanel ] = useState < Panel > ( null ) ;
2026-04-15 20:17:05 -06:00
const [ projectName , setProjectName ] = useState < string | null > ( null ) ;
2026-05-05 12:29:25 -06:00
const [ splitRatio , setSplitRatio ] = useState ( ( ) = > {
try { return Number ( localStorage . getItem ( 'talkedit:splitRatio' ) ) || 0.5 ; } catch { return 0.5 ; }
} ) ;
const splitRef = useRef < HTMLDivElement > ( null ) ;
const isDraggingSplit = useRef ( false ) ;
const startSplitDrag = useCallback ( ( e : React.MouseEvent ) = > {
e . preventDefault ( ) ;
isDraggingSplit . current = true ;
const container = splitRef . current ? . parentElement ;
if ( ! container ) return ;
const rect = container . getBoundingClientRect ( ) ;
const onMove = ( me : MouseEvent ) = > {
if ( ! isDraggingSplit . current ) return ;
const pct = ( me . clientX - rect . left ) / rect . width ;
const clamped = Math . max ( 0.15 , Math . min ( 0.85 , pct ) ) ;
setSplitRatio ( clamped ) ;
localStorage . setItem ( 'talkedit:splitRatio' , String ( clamped ) ) ;
} ;
const onUp = ( ) = > { isDraggingSplit . current = false ; window . removeEventListener ( 'mousemove' , onMove ) ; window . removeEventListener ( 'mouseup' , onUp ) ; } ;
window . addEventListener ( 'mousemove' , onMove ) ;
window . addEventListener ( 'mouseup' , onUp ) ;
} , [ ] ) ;
// Draggable right sidebar
const [ sidebarWidth , setSidebarWidth ] = useState ( ( ) = > {
try { return Number ( localStorage . getItem ( 'talkedit:sidebarWidth' ) ) || 320 ; } catch { return 320 ; }
} ) ;
const isDraggingSidebar = useRef ( false ) ;
const startSidebarDrag = useCallback ( ( e : React.MouseEvent ) = > {
e . preventDefault ( ) ;
isDraggingSidebar . current = true ;
const container = document . querySelector ( '.main-content' ) as HTMLElement ;
if ( ! container ) return ;
const rect = container . getBoundingClientRect ( ) ;
const onMove = ( me : MouseEvent ) = > {
if ( ! isDraggingSidebar . current ) return ;
const w = rect . right - me . clientX ;
const clamped = Math . max ( 180 , Math . min ( 600 , w ) ) ;
setSidebarWidth ( clamped ) ;
localStorage . setItem ( 'talkedit:sidebarWidth' , String ( clamped ) ) ;
} ;
const onUp = ( ) = > { isDraggingSidebar . current = false ; window . removeEventListener ( 'mousemove' , onMove ) ; window . removeEventListener ( 'mouseup' , onUp ) ; } ;
window . addEventListener ( 'mousemove' , onMove ) ;
window . addEventListener ( 'mouseup' , onUp ) ;
} , [ ] ) ;
2026-03-03 06:31:04 -05:00
const [ whisperModel , setWhisperModel ] = useState ( 'base' ) ;
2026-04-15 19:54:39 -06:00
useEffect ( ( ) = > { if ( transcriptionModel ) setWhisperModel ( transcriptionModel ) ; } , [ transcriptionModel ] ) ;
2026-04-03 11:14:31 -06:00
const [ cutMode , setCutMode ] = useState ( false ) ;
const [ muteMode , setMuteMode ] = useState ( false ) ;
2026-04-15 16:36:21 -06:00
const [ gainMode , setGainMode ] = useState ( false ) ;
const [ gainModeDb , setGainModeDb ] = useState ( 3 ) ;
2026-04-15 19:54:39 -06:00
const [ speedMode , setSpeedMode ] = useState ( false ) ;
const [ speedModeValue , setSpeedModeValue ] = useState ( 1.25 ) ;
2026-04-11 19:42:30 -06:00
const [ showReprocessConfirm , setShowReprocessConfirm ] = useState ( false ) ;
2026-04-15 16:10:35 -06:00
const [ showUnsavedPrompt , setShowUnsavedPrompt ] = useState ( false ) ;
const [ pendingProceedAction , setPendingProceedAction ] = useState < ( ( ) = > Promise < void > ) | null > ( null ) ;
const [ lastSavedSignature , setLastSavedSignature ] = useState < string | null > ( null ) ;
2026-05-06 01:35:42 -06:00
const [ showFileMenu , setShowFileMenu ] = useState ( false ) ;
const canEdit = useLicenseStore ( ( s ) = > s . canEdit ) ;
2026-05-06 01:43:55 -06:00
const licenseStatus = useLicenseStore ( ( s ) = > s . status ) ;
const setShowLicenseDialog = useLicenseStore ( ( s ) = > s . setShowDialog ) ;
2026-03-03 06:31:04 -05:00
2026-04-15 16:10:35 -06:00
const projectSignature = useMemo ( ( ) = > {
if ( ! videoPath ) return null ;
return JSON . stringify ( {
videoPath ,
exportedAudioPath ,
words ,
segments ,
cutRanges ,
muteRanges ,
gainRanges ,
2026-04-15 19:54:39 -06:00
speedRanges ,
2026-04-15 16:10:35 -06:00
globalGainDb ,
silenceTrimGroups ,
transcriptionModel ,
language ,
} ) ;
} , [
videoPath ,
exportedAudioPath ,
words ,
segments ,
cutRanges ,
muteRanges ,
gainRanges ,
2026-04-15 19:54:39 -06:00
speedRanges ,
2026-04-15 16:10:35 -06:00
globalGainDb ,
silenceTrimGroups ,
transcriptionModel ,
language ,
] ) ;
const hasUnsavedChanges = Boolean ( projectSignature ) && projectSignature !== lastSavedSignature ;
const loadProjectFromData = ( data : any ) = > {
useEditorStore . getState ( ) . loadProject ( data ) ;
const loadedSignature = JSON . stringify ( {
videoPath : data.videoPath ,
exportedAudioPath : data.exportedAudioPath ? ? null ,
words : data.words || [ ] ,
segments : data.segments || [ ] ,
2026-04-15 20:17:05 -06:00
cutRanges : [ . . . ( data . cutRanges || [ ] ) , . . . ( data . deletedRanges || [ ] ) . map ( ( r : any ) = > ( { id : r.id , start : r.start , end : r.end } ) ) ] ,
2026-04-15 16:10:35 -06:00
muteRanges : data.muteRanges || [ ] ,
gainRanges : data.gainRanges || [ ] ,
2026-04-15 19:54:39 -06:00
speedRanges : data.speedRanges || [ ] ,
2026-04-15 16:10:35 -06:00
globalGainDb : typeof data . globalGainDb === 'number' ? data.globalGainDb : 0 ,
silenceTrimGroups : data.silenceTrimGroups || [ ] ,
transcriptionModel : data.transcriptionModel ? ? null ,
language : data.language || '' ,
} ) ;
setLastSavedSignature ( loadedSignature ) ;
} ;
const runGuarded = async ( action : ( ) = > Promise < void > ) = > {
2026-04-15 17:40:27 -06:00
if ( ! hasUnsavedChanges ) {
2026-04-15 16:10:35 -06:00
await action ( ) ;
return ;
}
setPendingProceedAction ( ( ) = > action ) ;
setShowUnsavedPrompt ( true ) ;
} ;
2026-03-03 06:31:04 -05:00
useKeyboardShortcuts ( ) ;
2026-05-06 01:35:42 -06:00
useEffect ( ( ) = > {
useLicenseStore . getState ( ) . checkStatus ( ) ;
} , [ ] ) ;
// Handle Escape key to exit timeline zone modes and close menus
2026-04-03 11:14:31 -06:00
useEffect ( ( ) = > {
const handleKeyDown = ( e : KeyboardEvent ) = > {
if ( e . key === 'Escape' ) {
setCutMode ( false ) ;
setMuteMode ( false ) ;
2026-04-15 16:36:21 -06:00
setGainMode ( false ) ;
2026-04-15 19:54:39 -06:00
setSpeedMode ( false ) ;
2026-05-06 01:35:42 -06:00
setShowFileMenu ( false ) ;
2026-04-03 11:14:31 -06:00
}
} ;
window . addEventListener ( 'keydown' , handleKeyDown ) ;
return ( ) = > window . removeEventListener ( 'keydown' , handleKeyDown ) ;
} , [ ] ) ;
2026-03-03 06:31:04 -05:00
useEffect ( ( ) = > {
2026-04-15 17:40:27 -06:00
window . electronAPI ! . getBackendUrl ( ) . then ( setBackendUrl ) ;
2026-03-03 06:31:04 -05:00
} , [ setBackendUrl ] ) ;
2026-04-09 01:36:28 -06:00
useEffect ( ( ) = > {
2026-04-15 17:40:27 -06:00
if ( videoPath ) return ;
2026-04-09 01:36:28 -06:00
const savedPath = sessionStorage . getItem ( LAST_MEDIA_PATH_KEY ) ;
if ( savedPath ) {
loadVideo ( savedPath ) ;
}
} , [ videoPath , loadVideo ] ) ;
useEffect ( ( ) = > {
if ( videoPath ) {
sessionStorage . setItem ( LAST_MEDIA_PATH_KEY , videoPath ) ;
return ;
}
sessionStorage . removeItem ( LAST_MEDIA_PATH_KEY ) ;
} , [ videoPath ] ) ;
2026-03-03 06:31:04 -05:00
const handleLoadProject = async ( ) = > {
2026-04-15 16:10:35 -06:00
await runGuarded ( async ( ) = > {
try {
const projectPath = await window . electronAPI ! . openProject ( ) ;
if ( ! projectPath ) return ;
const content = await window . electronAPI ! . readFile ( projectPath ) ;
const data = JSON . parse ( content ) ;
2026-04-15 20:51:24 -06:00
setProjectFilePath ( projectPath ) ;
2026-04-15 16:10:35 -06:00
loadProjectFromData ( data ) ;
2026-04-15 20:17:05 -06:00
setProjectName ( projectPath . split ( /[/\\]/ ) . pop ( ) ? . replace ( /\.aive$/i , '' ) ? ? null ) ;
2026-04-15 16:10:35 -06:00
} catch ( err ) {
console . error ( 'Failed to load project:' , err ) ;
alert ( ` Failed to load project: ${ err } ` ) ;
}
} ) ;
2026-03-03 06:31:04 -05:00
} ;
2026-04-15 20:51:24 -06:00
const writeProjectToPath = async ( path : string ) : Promise < boolean > = > {
2026-03-30 18:36:41 -06:00
try {
const data = useEditorStore . getState ( ) . saveProject ( ) ;
2026-04-15 20:51:24 -06:00
const resolvedPath = path . endsWith ( '.aive' ) ? path : ` ${ path } .aive ` ;
await window . electronAPI ! . writeFile ( resolvedPath , JSON . stringify ( data , null , 2 ) ) ;
setProjectFilePath ( resolvedPath ) ;
setProjectName ( resolvedPath . split ( /[/\\]/ ) . pop ( ) ? . replace ( /\.aive$/i , '' ) ? ? null ) ;
2026-04-15 16:10:35 -06:00
if ( projectSignature ) {
setLastSavedSignature ( projectSignature ) ;
}
return true ;
2026-03-30 18:36:41 -06:00
} catch ( err ) {
console . error ( 'Failed to save project:' , err ) ;
alert ( ` Failed to save project: ${ err } ` ) ;
2026-04-15 16:10:35 -06:00
return false ;
2026-03-30 18:36:41 -06:00
}
} ;
2026-04-15 20:51:24 -06:00
const handleSaveProjectAs = async ( ) : Promise < boolean > = > {
const savePath = await window . electronAPI ! . saveProject ( ) ;
if ( ! savePath ) return false ;
return writeProjectToPath ( savePath ) ;
} ;
const handleSaveProject = async ( ) : Promise < boolean > = > {
if ( ! projectFilePath ) {
return handleSaveProjectAs ( ) ;
}
return writeProjectToPath ( projectFilePath ) ;
} ;
2026-03-03 06:31:04 -05:00
const handleOpenFile = async ( ) = > {
2026-04-15 16:10:35 -06:00
await runGuarded ( async ( ) = > {
2026-04-15 17:40:27 -06:00
const path = await window . electronAPI ! . openFile ( ) ;
if ( path ) {
setLastSavedSignature ( null ) ;
2026-04-15 20:51:24 -06:00
setProjectFilePath ( null ) ;
setProjectName ( null ) ;
2026-04-15 17:40:27 -06:00
loadVideo ( path ) ;
2026-05-06 01:35:42 -06:00
if ( canEdit ) {
await transcribeVideo ( path ) ;
}
2026-03-03 06:31:04 -05:00
}
2026-04-15 16:10:35 -06:00
} ) ;
2026-03-03 06:31:04 -05:00
} ;
2026-04-11 19:42:30 -06:00
const handleNewProject = ( ) = > {
2026-04-15 16:10:35 -06:00
runGuarded ( async ( ) = > {
useEditorStore . getState ( ) . reset ( ) ;
setLastSavedSignature ( null ) ;
setActivePanel ( null ) ;
2026-04-15 20:51:24 -06:00
setProjectFilePath ( null ) ;
setProjectName ( null ) ;
2026-04-15 16:10:35 -06:00
setCutMode ( false ) ;
setMuteMode ( false ) ;
2026-04-15 16:36:21 -06:00
setGainMode ( false ) ;
2026-04-15 19:54:39 -06:00
setSpeedMode ( false ) ;
2026-04-15 16:10:35 -06:00
} ) ;
2026-04-11 19:42:30 -06:00
} ;
2026-03-03 06:31:04 -05:00
const transcribeVideo = async ( path : string ) = > {
2026-03-26 00:58:57 -06:00
setTranscribing ( true , 0 , 'Checking model...' ) ;
2026-03-03 06:31:04 -05:00
try {
2026-03-26 00:58:57 -06:00
// Step 1: ensure model is downloaded (may take a while on first run)
2026-04-03 10:25:48 -06:00
const MODEL_SIZES : Record < string , string > = {
'tiny' : '~75 MB' ,
'tiny.en' : '~75 MB' ,
'base' : '~140 MB' ,
'base.en' : '~140 MB' ,
'small' : '~460 MB' ,
'small.en' : '~460 MB' ,
'medium' : '~1.5 GB' ,
'medium.en' : '~1.5 GB' ,
'large' : '~2.9 GB' ,
'large-v2' : '~2.9 GB' ,
'large-v3' : '~2.9 GB' ,
'large-v3-turbo' : '~1.6 GB' ,
'distil-large-v3' : '~1.5 GB' ,
'distil-medium.en' : '~750 MB' ,
'distil-small.en' : '~190 MB' ,
} ;
const modelLabel = MODEL_SIZES [ whisperModel ] ? ? 'unknown size' ;
2026-03-26 00:58:57 -06:00
setTranscribing ( true , 5 , ` Downloading ${ whisperModel } model ( ${ modelLabel } )... ` ) ;
await window . electronAPI . ensureModel ( whisperModel ) ;
// Step 2: run transcription
setTranscribing ( true , 20 , 'Transcribing audio...' ) ;
const data = await window . electronAPI . transcribe ( path , whisperModel ) ;
2026-03-03 06:31:04 -05:00
setTranscription ( data ) ;
2026-04-11 19:42:30 -06:00
setTranscriptionModel ( whisperModel ) ;
2026-03-03 06:31:04 -05:00
} catch ( err ) {
console . error ( 'Transcription error:' , err ) ;
alert ( ` Transcription failed. Check the console for details. \ n \ n ${ err } ` ) ;
} finally {
setTranscribing ( false ) ;
}
} ;
2026-04-11 19:42:30 -06:00
const handleReprocessProject = async ( ) = > {
if ( ! videoPath ) return ;
2026-04-15 16:10:35 -06:00
await runGuarded ( async ( ) = > {
setShowReprocessConfirm ( true ) ;
} ) ;
2026-04-11 19:42:30 -06:00
} ;
const confirmReprocessProject = async ( ) = > {
if ( ! videoPath ) return ;
setShowReprocessConfirm ( false ) ;
await transcribeVideo ( videoPath ) ;
} ;
2026-04-15 16:10:35 -06:00
const handleUnsavedSaveAndContinue = async ( ) = > {
const action = pendingProceedAction ;
if ( ! action ) {
setShowUnsavedPrompt ( false ) ;
return ;
}
const didSave = await handleSaveProject ( ) ;
if ( ! didSave ) return ;
setShowUnsavedPrompt ( false ) ;
setPendingProceedAction ( null ) ;
await action ( ) ;
} ;
const handleUnsavedDiscardAndContinue = async ( ) = > {
const action = pendingProceedAction ;
setShowUnsavedPrompt ( false ) ;
setPendingProceedAction ( null ) ;
if ( action ) {
await action ( ) ;
}
} ;
const handleUnsavedCancel = ( ) = > {
setShowUnsavedPrompt ( false ) ;
setPendingProceedAction ( null ) ;
} ;
2026-04-03 12:05:44 -06:00
const togglePanel = ( panel : Panel ) = > {
setActivePanel ( ( prev ) = > ( prev === panel ? null : panel ) ) ;
} ;
2026-04-03 11:14:31 -06:00
const handleCut = ( ) = > {
2026-04-15 21:25:47 -06:00
if ( markInTime !== null && markOutTime !== null ) {
const startTime = Math . min ( markInTime , markOutTime ) ;
const endTime = Math . max ( markInTime , markOutTime ) ;
if ( endTime - startTime >= 0.01 ) {
addCutRange ( startTime , endTime ) ;
setActivePanel ( 'zones' ) ;
}
clearMarkRange ( ) ;
return ;
}
2026-04-03 11:14:31 -06:00
if ( selectedWordIndices . length > 0 ) {
// If words are selected, apply cut immediately
const sorted = [ . . . selectedWordIndices ] . sort ( ( a , b ) = > a - b ) ;
const startTime = words [ sorted [ 0 ] ] . start ;
const endTime = words [ sorted [ sorted . length - 1 ] ] . end ;
addCutRange ( startTime , endTime ) ;
} else {
// Toggle cut mode
setCutMode ( ! cutMode ) ;
setMuteMode ( false ) ; // Exit mute mode
2026-04-15 16:36:21 -06:00
setGainMode ( false ) ; // Exit gain mode
2026-04-15 19:54:39 -06:00
setSpeedMode ( false ) ; // Exit speed mode
2026-04-03 11:14:31 -06:00
}
} ;
const handleMute = ( ) = > {
2026-04-15 21:25:47 -06:00
if ( markInTime !== null && markOutTime !== null ) {
const startTime = Math . min ( markInTime , markOutTime ) ;
const endTime = Math . max ( markInTime , markOutTime ) ;
if ( endTime - startTime >= 0.01 ) {
addMuteRange ( startTime , endTime ) ;
setActivePanel ( 'zones' ) ;
}
clearMarkRange ( ) ;
return ;
}
2026-04-03 11:14:31 -06:00
if ( selectedWordIndices . length > 0 ) {
// If words are selected, apply mute immediately
const sorted = [ . . . selectedWordIndices ] . sort ( ( a , b ) = > a - b ) ;
const startTime = words [ sorted [ 0 ] ] . start ;
const endTime = words [ sorted [ sorted . length - 1 ] ] . end ;
addMuteRange ( startTime , endTime ) ;
} else {
// Toggle mute mode
setMuteMode ( ! muteMode ) ;
setCutMode ( false ) ; // Exit cut mode
2026-04-15 16:36:21 -06:00
setGainMode ( false ) ; // Exit gain mode
2026-04-15 19:54:39 -06:00
setSpeedMode ( false ) ; // Exit speed mode
2026-04-15 16:36:21 -06:00
}
} ;
const handleGain = ( ) = > {
2026-04-15 21:25:47 -06:00
if ( markInTime !== null && markOutTime !== null ) {
const startTime = Math . min ( markInTime , markOutTime ) ;
const endTime = Math . max ( markInTime , markOutTime ) ;
if ( endTime - startTime >= 0.01 ) {
addGainRange ( startTime , endTime , gainModeDb ) ;
setActivePanel ( 'zones' ) ;
}
clearMarkRange ( ) ;
return ;
}
2026-04-15 16:36:21 -06:00
if ( selectedWordIndices . length > 0 ) {
const sorted = [ . . . selectedWordIndices ] . sort ( ( a , b ) = > a - b ) ;
const startTime = words [ sorted [ 0 ] ] . start ;
const endTime = words [ sorted [ sorted . length - 1 ] ] . end ;
addGainRange ( startTime , endTime , gainModeDb ) ;
} else {
setGainMode ( ! gainMode ) ;
setCutMode ( false ) ;
setMuteMode ( false ) ;
2026-04-15 19:54:39 -06:00
setSpeedMode ( false ) ;
}
} ;
const handleSpeed = ( ) = > {
2026-04-15 21:25:47 -06:00
if ( markInTime !== null && markOutTime !== null ) {
const startTime = Math . min ( markInTime , markOutTime ) ;
const endTime = Math . max ( markInTime , markOutTime ) ;
if ( endTime - startTime >= 0.01 ) {
addSpeedRange ( startTime , endTime , speedModeValue ) ;
setActivePanel ( 'zones' ) ;
}
clearMarkRange ( ) ;
return ;
}
2026-04-15 19:54:39 -06:00
if ( selectedWordIndices . length > 0 ) {
const sorted = [ . . . selectedWordIndices ] . sort ( ( a , b ) = > a - b ) ;
const startTime = words [ sorted [ 0 ] ] . start ;
const endTime = words [ sorted [ sorted . length - 1 ] ] . end ;
addSpeedRange ( startTime , endTime , speedModeValue ) ;
} else {
setSpeedMode ( ! speedMode ) ;
setCutMode ( false ) ;
setMuteMode ( false ) ;
setGainMode ( false ) ;
2026-04-03 11:14:31 -06:00
}
} ;
2026-03-03 06:31:04 -05:00
if ( ! videoPath ) {
return (
2026-05-06 01:43:55 -06:00
< div className = "h-screen flex flex-col bg-editor-bg" >
< div className = "flex-1 flex flex-col items-center justify-center gap-8 px-6" >
< div className = "flex flex-col items-center gap-3" >
< Film className = "w-14 h-14 text-editor-accent opacity-80" / >
< h1 className = "text-3xl font-semibold tracking-tight" > TalkEdit < / h1 >
< p className = "text-editor-text-muted text-sm max-w-sm text-center" >
Offline AI - powered video editor .
< / p >
< / div >
{ /* Whisper model selector */ }
< div className = "flex items-center gap-3" >
< label className = "text-xs text-editor-text-muted whitespace-nowrap" > Model : < / label >
< select
value = { whisperModel }
onChange = { ( e ) = > setWhisperModel ( e . target . value ) }
className = "px-3 py-1.5 bg-editor-surface border border-editor-border rounded-lg text-xs text-black focus:outline-none focus:border-editor-accent"
>
< optgroup label = "Multilingual (any language)" >
< option value = "tiny" > tiny — ~ 75 MB · fastest , low accuracy < / option >
< option value = "base" > base — ~ 140 MB · fast , decent accuracy < / option >
< option value = "small" > small — ~ 460 MB · good balance < / option >
< option value = "medium" > medium — ~ 1.5 GB · better accuracy < / option >
< option value = "large-v2" > large - v2 — ~ 2.9 GB · high accuracy < / option >
< option value = "large-v3" > large - v3 — ~ 2.9 GB · best overall ★ < / option >
< option value = "large-v3-turbo" > large - v3 - turbo — ~ 1.6 GB · fast + accurate ★ < / option >
< option value = "distil-large-v3" > distil - large - v3 — ~ 1.5 GB · fast , near large - v3 quality < / option >
< / optgroup >
< optgroup label = "English-only (faster & more accurate for English)" >
< option value = "tiny.en" > tiny . en — ~ 75 MB · fastest English < / option >
< option value = "base.en" > base . en — ~ 140 MB · fast English < / option >
< option value = "small.en" > small . en — ~ 460 MB · good English < / option >
< option value = "medium.en" > medium . en — ~ 1.5 GB · great English < / option >
< option value = "distil-small.en" > distil - small . en — ~ 190 MB · fast English ★ < / option >
< option value = "distil-medium.en" > distil - medium . en — ~ 750 MB · best fast English ★ < / option >
< / optgroup >
< / select >
< / div >
< p className = "text-[11px] text-editor-text-muted text-center max-w-sm" >
For noisy / YouTube videos use < span className = "text-white" > large - v3 < / span > or < span className = "text-white" > large - v3 - turbo < / span > .
English - only models are ~ 10 % faster and more accurate for English content .
2026-03-03 06:31:04 -05:00
< / p >
2026-05-06 01:43:55 -06:00
< div className = "flex flex-col items-center gap-3" >
< button
onClick = { handleOpenFile }
className = "flex items-center gap-2 px-6 py-3 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-white font-medium transition-colors"
>
< FolderOpen className = "w-5 h-5" / >
Open Video File
< / button >
< button
onClick = { handleLoadProject }
className = "flex items-center gap-2 px-4 py-2 text-sm text-editor-text-muted hover:text-editor-text hover:bg-editor-surface rounded-lg transition-colors"
>
< FileInput className = "w-4 h-4" / >
Load Project ( . aive )
< / button >
< / div >
2026-04-15 17:40:27 -06:00
< / div >
2026-05-06 01:43:55 -06:00
{ licenseStatus ? . tag === 'Trial' && (
< div className = "h-9 flex items-center justify-center gap-2 px-4 bg-editor-accent/10 border-t border-editor-accent/20 shrink-0" >
< Clock className = "w-3.5 h-3.5 text-editor-accent shrink-0" / >
< span className = "text-xs text-editor-accent" >
Free trial : { licenseStatus . days_remaining } day { licenseStatus . days_remaining !== 1 ? 's' : '' } remaining
< / span >
< button
onClick = { ( ) = > setShowLicenseDialog ( true ) }
className = "text-xs text-editor-accent underline font-medium hover:text-editor-accent-hover ml-2"
>
Activate license
< / button >
< / div >
) }
{ licenseStatus ? . tag === 'Expired' && (
< div className = "h-9 flex items-center justify-center gap-2 px-4 bg-red-500/15 border-t border-red-500/30 shrink-0" >
< AlertTriangle className = "w-3.5 h-3.5 text-red-400 shrink-0" / >
< span className = "text-xs text-red-300" > Trial expired < / span >
< button
onClick = { ( ) = > setShowLicenseDialog ( true ) }
className = "text-xs text-red-300 underline font-medium hover:text-red-200 ml-2"
>
Activate license
< / button >
< / div >
) }
2026-03-03 06:31:04 -05:00
< / div >
) ;
}
return (
< div className = "h-screen flex flex-col bg-editor-bg overflow-hidden" >
{ /* Top bar */ }
2026-04-15 19:54:39 -06:00
< header className = "h-12 flex items-center px-4 border-b border-editor-border shrink-0" >
< div className = "flex items-center gap-0.5" >
2026-05-06 01:35:42 -06:00
< div className = "relative" >
< ToolbarButton
icon = { < FolderOpen className = "w-4 h-4" / > }
label = "File"
onClick = { ( ) = > setShowFileMenu ( ( p ) = > ! p ) }
active = { showFileMenu }
/ >
{ showFileMenu && (
< >
< div className = "fixed inset-0 z-40" onClick = { ( ) = > setShowFileMenu ( false ) } / >
< div className = "absolute left-0 top-full mt-1 z-50 w-44 rounded-lg border border-editor-border bg-editor-surface shadow-xl py-1" >
2026-05-06 10:53:27 -06:00
< DropdownItem icon = { < FilePlus2 className = "w-4 h-4" / > } label = "New Project" title = "Start a new empty project" onClick = { ( ) = > { setShowFileMenu ( false ) ; handleNewProject ( ) ; } } / >
< DropdownItem icon = { < FolderOpen className = "w-4 h-4" / > } label = "Open File" title = "Open a video or audio file for transcription" onClick = { ( ) = > { setShowFileMenu ( false ) ; handleOpenFile ( ) ; } } / >
< DropdownItem icon = { < FileInput className = "w-4 h-4" / > } label = "Load Project" title = "Open a saved .aive project file" onClick = { ( ) = > { setShowFileMenu ( false ) ; handleLoadProject ( ) ; } } / >
2026-05-06 01:35:42 -06:00
< div className = "border-t border-editor-border my-1" / >
2026-05-06 10:53:27 -06:00
< DropdownItem icon = { < Save className = "w-4 h-4" / > } label = "Save" title = "Save current project" onClick = { ( ) = > { setShowFileMenu ( false ) ; handleSaveProject ( ) ; } } disabled = { words . length === 0 } / >
< DropdownItem icon = { < Save className = "w-4 h-4" / > } label = "Save As" title = "Save a copy of the current project" onClick = { ( ) = > { setShowFileMenu ( false ) ; handleSaveProjectAs ( ) ; } } disabled = { words . length === 0 } / >
2026-05-06 01:35:42 -06:00
< / div >
< / >
) }
< / div >
2026-04-15 18:00:34 -06:00
< ToolbarButton
2026-05-06 10:53:27 -06:00
icon = { < Scissors className = "w-4 h-4" / > }
label = "Cut"
onClick = { handleCut }
active = { cutMode }
2026-05-06 01:35:42 -06:00
disabled = { ! canEdit }
2026-05-06 10:53:27 -06:00
title = "Cut selected word range or mark in/out area — removes the segment from output"
2026-04-15 18:00:34 -06:00
/ >
2026-05-06 10:53:27 -06:00
< ToolbarButton
icon = { < VolumeX className = "w-4 h-4" / > }
label = "Mute"
onClick = { handleMute }
active = { muteMode }
2026-05-06 01:35:42 -06:00
disabled = { ! canEdit }
2026-05-06 10:53:27 -06:00
title = "Mute selected word range or mark in/out area — silences audio, keeps video"
2026-04-15 18:00:34 -06:00
/ >
2026-05-06 10:53:27 -06:00
< div className = "flex items-center gap-1" >
< ToolbarButton
icon = { < SlidersHorizontal className = "w-4 h-4" / > }
label = "Gain Zone"
onClick = { handleGain }
active = { gainMode }
disabled = { ! canEdit }
title = "Add gain zone from selection or mark in/out — adjust volume up or down"
/ >
< input
type = "number"
min = { - 24 }
max = { 24 }
step = { 0.5 }
value = { gainModeDb }
onChange = { ( e ) = > setGainModeDb ( Math . max ( - 24 , Math . min ( 24 , Number ( e . target . value ) || 0 ) ) ) }
className = "w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
data - tooltip = "Volume adjustment in decibels for new gain zones — positive boosts, negative reduces"
disabled = { ! canEdit }
/ >
< / div >
< div className = "flex items-center gap-1" >
< ToolbarButton
icon = { < Gauge className = "w-4 h-4" / > }
label = "Speed Zone"
onClick = { handleSpeed }
active = { speedMode }
disabled = { ! canEdit }
title = "Add speed zone from selection or mark in/out — change playback speed"
/ >
< input
type = "number"
min = { 0.25 }
max = { 4 }
step = { 0.05 }
value = { speedModeValue }
onChange = { ( e ) = > setSpeedModeValue ( Math . max ( 0.25 , Math . min ( 4 , Number ( e . target . value ) || 1 ) ) ) }
className = "w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
data - tooltip = "Playback speed multiplier for new speed zones — 1x is normal, 2x is double speed"
disabled = { ! canEdit }
/ >
< / div >
2026-04-15 19:54:39 -06:00
< ToolbarButton
2026-05-06 10:53:27 -06:00
icon = { < Grid3x3 className = "w-4 h-4" / > }
label = "Zones"
active = { activePanel === 'zones' }
onClick = { ( ) = > togglePanel ( 'zones' ) }
disabled = { ! videoPath || ! canEdit }
title = "Open zone editor panel — view and manage all cut, mute, gain, and speed zones"
2026-04-15 19:54:39 -06:00
/ >
2026-05-06 10:53:27 -06:00
< ToolbarButton
icon = { < span className = "text-[10px] font-semibold" > PA < / span > }
label = "Pause Trim"
active = { activePanel === 'silence' }
onClick = { ( ) = > togglePanel ( 'silence' ) }
disabled = { ! videoPath || ! canEdit }
title = "Detect and remove silent pauses — batch-removes silence above a configurable threshold"
/ >
< ToolbarButton
icon = { < MapPin className = "w-4 h-4" / > }
label = "Markers"
active = { activePanel === 'markers' }
onClick = { ( ) = > togglePanel ( 'markers' ) }
disabled = { ! videoPath || ! canEdit }
title = "Add and manage timeline markers — chapter points, key moments, YouTube timestamps"
/ >
< ToolbarButton
icon = { < Music className = "w-4 h-4" / > }
label = "Music"
active = { activePanel === 'music' }
onClick = { ( ) = > togglePanel ( 'music' ) }
disabled = { ! videoPath || ! canEdit }
title = "Add background music track with auto-ducking — music lowers when someone speaks"
/ >
< ToolbarButton
icon = { < ListVideo className = "w-4 h-4" / > }
label = "Append"
active = { activePanel === 'append' }
onClick = { ( ) = > togglePanel ( 'append' ) }
disabled = { ! videoPath || ! canEdit }
title = "Append additional video clips — concatenate multiple files during export"
2026-04-15 19:54:39 -06:00
/ >
2026-04-11 19:42:30 -06:00
< div className = "flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border" >
< select
value = { whisperModel }
onChange = { ( e ) = > setWhisperModel ( e . target . value ) }
2026-04-15 19:54:39 -06:00
className = "bg-editor-surface text-xs text-editor-text focus:outline-none [color-scheme:dark]"
2026-04-11 19:42:30 -06:00
title = "Transcription model"
>
< optgroup label = "Multilingual" >
< option value = "tiny" > tiny < / option >
< option value = "base" > base < / option >
< option value = "small" > small < / option >
< option value = "medium" > medium < / option >
< option value = "large-v2" > large - v2 < / option >
< option value = "large-v3" > large - v3 < / option >
< option value = "large-v3-turbo" > large - v3 - turbo < / option >
< option value = "distil-large-v3" > distil - large - v3 < / option >
< / optgroup >
< optgroup label = "English" >
< option value = "tiny.en" > tiny . en < / option >
< option value = "base.en" > base . en < / option >
< option value = "small.en" > small . en < / option >
< option value = "medium.en" > medium . en < / option >
< option value = "distil-small.en" > distil - small . en < / option >
< option value = "distil-medium.en" > distil - medium . en < / option >
< / optgroup >
< / select >
< button
onClick = { handleReprocessProject }
2026-05-06 01:35:42 -06:00
disabled = { isTranscribing || ! videoPath || ! canEdit }
2026-05-06 10:53:27 -06:00
data - tooltip = "Re-run transcription with the selected Whisper model — replaces current transcript"
2026-04-15 19:54:39 -06:00
className = "flex items-center gap-1 px-2 py-1 rounded text-xs text-editor-text hover:bg-editor-bg disabled:opacity-40 disabled:cursor-not-allowed"
2026-04-11 19:42:30 -06:00
>
< RefreshCw className = { ` w-3 h-3 ${ isTranscribing ? 'animate-spin' : '' } ` } / >
Reprocess
< / button >
< / div >
2026-03-03 06:31:04 -05:00
< ToolbarButton
icon = { < Sparkles className = "w-4 h-4" / > }
label = "AI"
active = { activePanel === 'ai' }
onClick = { ( ) = > togglePanel ( 'ai' ) }
2026-05-06 01:35:42 -06:00
disabled = { words . length === 0 || ! canEdit }
2026-05-06 10:53:27 -06:00
title = "AI filler detection, clip suggestions, and transcript analysis"
2026-03-03 06:31:04 -05:00
/ >
< ToolbarButton
icon = { < Download className = "w-4 h-4" / > }
label = "Export"
active = { activePanel === 'export' }
onClick = { ( ) = > togglePanel ( 'export' ) }
disabled = { words . length === 0 }
/ >
< ToolbarButton
icon = { < Settings className = "w-4 h-4" / > }
label = "Settings"
active = { activePanel === 'settings' }
onClick = { ( ) = > togglePanel ( 'settings' ) }
/ >
< / div >
< / header >
{ /* Main content */ }
2026-05-05 12:29:25 -06:00
< div className = "main-content flex-1 flex overflow-hidden" >
2026-03-03 06:31:04 -05:00
{ /* Left: video + transcript */ }
< div className = "flex-1 flex flex-col min-w-0" >
2026-05-05 12:29:25 -06:00
< div ref = { splitRef } className = "flex-1 flex min-h-0" style = { { position : 'relative' } } >
2026-03-03 06:31:04 -05:00
{ /* Video player */ }
2026-05-05 12:29:25 -06:00
< div className = "p-3 flex items-center justify-center bg-black/20 overflow-hidden" style = { { width : ` ${ splitRatio * 100 } % ` , minWidth : 0 } } >
2026-03-03 06:31:04 -05:00
< VideoPlayer / >
< / div >
2026-05-05 12:29:25 -06:00
{ /* Draggable divider */ }
< div
className = "w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
style = { { cursor : isDraggingSplit.current ? 'col-resize' : 'col-resize' } }
onMouseDown = { startSplitDrag }
/ >
2026-03-03 06:31:04 -05:00
{ /* Transcript */ }
2026-05-05 12:29:25 -06:00
< div className = "border-l border-editor-border flex flex-col min-h-0" style = { { width : ` ${ ( 1 - splitRatio ) * 100 } % ` , minWidth : 0 } } >
2026-04-15 19:54:39 -06:00
{ videoPath && (
< div className = "flex items-center gap-2 px-3 py-1.5 border-b border-editor-border shrink-0 bg-editor-surface/50" >
2026-04-15 20:17:05 -06:00
{ projectName && (
< span className = "text-xs font-semibold text-editor-accent shrink-0" > { projectName } < / span >
) }
< span className = "text-xs font-medium truncate text-editor-text" > { videoPath . split ( /[/\\]/ ) . pop ( ) } < / span >
< span className = "text-xs text-editor-text-muted ml-auto shrink-0" >
{ words . length } words & middot ; { cutRanges . length } cuts & middot ; { muteRanges . length } mutes & middot ; { gainRanges . length } gains & middot ; { speedRanges . length } speeds
< / span >
2026-04-15 19:54:39 -06:00
{ transcriptionModel && (
< span className = "px-1.5 py-0.5 rounded border border-editor-border bg-editor-surface text-[10px] uppercase tracking-wide text-editor-text-muted shrink-0" >
{ transcriptionModel }
< / span >
) }
< / div >
) }
2026-03-03 06:31:04 -05:00
{ isTranscribing ? (
2026-03-26 00:58:57 -06:00
< div className = "flex-1 flex flex-col items-center justify-center gap-5" >
{ /* Animated waveform */ }
< div className = "flex items-end gap-[3px] h-10" >
{ [ 35 , 60 , 45 , 80 , 55 , 70 , 40 , 65 , 50 , 75 , 40 , 58 ] . map ( ( h , i ) = > (
< div
key = { i }
className = "w-[5px] rounded-full bg-editor-accent wave-bar"
style = { {
height : ` ${ h } % ` ,
animationDelay : ` ${ i * 75 } ms ` ,
} }
/ >
) ) }
< / div >
< div className = "text-center space-y-1" >
< p className = "text-sm font-medium text-editor-text" > Processing audio < / p >
< p className = "text-xs text-editor-text-muted" > { transcriptionStatus || 'Please wait...' } < / p >
< / div >
2026-03-03 06:31:04 -05:00
< / div >
) : words . length > 0 ? (
2026-04-15 18:00:34 -06:00
< TranscriptEditor
cutMode = { cutMode }
muteMode = { muteMode }
gainMode = { gainMode }
gainModeDb = { gainModeDb }
2026-04-15 19:54:39 -06:00
speedMode = { speedMode }
speedModeValue = { speedModeValue }
2026-04-15 18:00:34 -06:00
/ >
2026-03-03 06:31:04 -05:00
) : (
< div className = "flex-1 flex items-center justify-center text-editor-text-muted text-sm" >
No transcript yet
< / div >
) }
< / div >
< / div >
{ /* Waveform timeline */ }
< div className = "h-32 border-t border-editor-border shrink-0" >
2026-04-15 19:54:39 -06:00
< WaveformTimeline
cutMode = { cutMode }
muteMode = { muteMode }
gainMode = { gainMode }
gainModeDb = { gainModeDb }
speedMode = { speedMode }
speedModeValue = { speedModeValue }
/ >
2026-03-03 06:31:04 -05:00
< / div >
< / div >
{ /* Right panel (AI / Export / Settings) */ }
{ activePanel && (
2026-05-05 12:29:25 -06:00
< div className = "flex shrink-0" >
{ /* Draggable sidebar divider */ }
< div
className = "w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
onMouseDown = { startSidebarDrag }
/ >
< div className = "overflow-y-auto" style = { { width : sidebarWidth } } >
2026-04-15 18:00:34 -06:00
{ activePanel === 'zones' && (
< ZoneEditor / >
2026-04-15 16:36:21 -06:00
) }
2026-04-03 12:05:44 -06:00
{ activePanel === 'silence' && < SilenceTrimmerPanel / > }
2026-05-05 10:22:35 -06:00
{ activePanel === 'markers' && < MarkersPanel / > }
2026-05-05 20:46:55 -06:00
{ activePanel === 'music' && < BackgroundMusicPanel / > }
{ activePanel === 'append' && < AppendClipPanel / > }
2026-03-03 06:31:04 -05:00
{ activePanel === 'ai' && < AIPanel / > }
{ activePanel === 'export' && < ExportDialog / > }
{ activePanel === 'settings' && < SettingsPanel / > }
< / div >
2026-05-05 12:29:25 -06:00
< / div >
) }
2026-03-03 06:31:04 -05:00
< / div >
2026-03-30 18:36:41 -06:00
{ import . meta . env . DEV && < DevPanel / > }
2026-04-11 19:42:30 -06:00
2026-05-06 01:35:42 -06:00
< LicenseDialog / >
2026-04-11 19:42:30 -06:00
{ showReprocessConfirm && (
< div
className = "fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
onClick = { ( ) = > setShowReprocessConfirm ( false ) }
>
< div
className = "w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-4 space-y-3"
onClick = { ( e ) = > e . stopPropagation ( ) }
>
< h3 className = "text-sm font-semibold" > Reprocess transcript ? < / h3 >
< p className = "text-xs text-editor-text-muted leading-relaxed" >
This will reprocess the current file with < span className = "text-editor-text font-medium" > { whisperModel } < / span > and replace the current transcript words and timings .
< / p >
< div className = "flex items-center justify-end gap-2 pt-1" >
< button
onClick = { ( ) = > setShowReprocessConfirm ( false ) }
className = "px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
>
Cancel
< / button >
< button
onClick = { confirmReprocessProject }
className = "px-3 py-1.5 rounded-md text-xs bg-editor-accent hover:bg-editor-accent-hover text-white"
>
Reprocess Now
< / button >
< / div >
< / div >
< / div >
) }
2026-04-15 16:10:35 -06:00
{ showUnsavedPrompt && (
< div
className = "fixed inset-0 z-[70] flex items-center justify-center bg-black/60 px-4"
onClick = { handleUnsavedCancel }
>
< div
className = "w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-4 space-y-3"
onClick = { ( e ) = > e . stopPropagation ( ) }
>
< h3 className = "text-sm font-semibold" > Save changes first ? < / h3 >
< p className = "text-xs text-editor-text-muted leading-relaxed" >
There are unsaved changes in this project . Save before continuing ?
< / p >
< div className = "flex items-center justify-end gap-2 pt-1" >
< button
onClick = { handleUnsavedCancel }
className = "px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
>
Cancel
< / button >
< button
onClick = { handleUnsavedDiscardAndContinue }
className = "px-3 py-1.5 rounded-md text-xs text-editor-text hover:bg-editor-surface"
>
Don ' t Save
< / button >
< button
onClick = { handleUnsavedSaveAndContinue }
className = "px-3 py-1.5 rounded-md text-xs bg-editor-accent hover:bg-editor-accent-hover text-white"
>
Save
< / button >
< / div >
< / div >
< / div >
) }
2026-03-03 06:31:04 -05:00
< / div >
) ;
}
function ToolbarButton ( {
icon ,
label ,
active ,
onClick ,
disabled ,
2026-05-06 10:53:27 -06:00
title ,
2026-03-03 06:31:04 -05:00
} : {
icon : React.ReactNode ;
label : string ;
active? : boolean ;
onClick : ( ) = > void ;
disabled? : boolean ;
2026-05-06 10:53:27 -06:00
title? : string ;
2026-03-03 06:31:04 -05:00
} ) {
return (
2026-05-06 10:53:27 -06:00
< span data-tooltip = { title || label } >
< button
onClick = { onClick }
disabled = { disabled }
className = { ` flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
active
? 'bg-editor-accent text-white'
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-surface'
} $ { disabled ? 'opacity-40 cursor-not-allowed' : '' } ` }
>
{ icon }
{ label }
< / button >
< / span >
2026-03-03 06:31:04 -05:00
) ;
}
2026-05-06 01:35:42 -06:00
function DropdownItem ( {
icon ,
label ,
onClick ,
disabled ,
2026-05-06 10:53:27 -06:00
title ,
2026-05-06 01:35:42 -06:00
} : {
icon : React.ReactNode ;
label : string ;
onClick : ( ) = > void ;
disabled? : boolean ;
2026-05-06 10:53:27 -06:00
title? : string ;
2026-05-06 01:35:42 -06:00
} ) {
return (
2026-05-06 10:53:27 -06:00
< span data-tooltip = { title || label } >
< button
onClick = { onClick }
disabled = { disabled }
className = { ` w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors ${
disabled
? 'opacity-40 cursor-not-allowed'
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-bg'
} ` }
>
{ icon }
{ label }
< / button >
< / span >
2026-05-06 01:35:42 -06:00
) ;
}