added licensing stuff and free trial timer
This commit is contained in:
@ -12,7 +12,9 @@ import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
|
||||
import ZoneEditor from './components/ZoneEditor';
|
||||
import BackgroundMusicPanel from './components/BackgroundMusicPanel';
|
||||
import AppendClipPanel from './components/AppendClipPanel';
|
||||
import LicenseDialog from './components/LicenseDialog';
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||
import { useLicenseStore } from './store/licenseStore';
|
||||
import {
|
||||
Film,
|
||||
FolderOpen,
|
||||
@ -132,6 +134,8 @@ export default function App() {
|
||||
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
|
||||
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
|
||||
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
|
||||
const [showFileMenu, setShowFileMenu] = useState(false);
|
||||
const canEdit = useLicenseStore((s) => s.canEdit);
|
||||
|
||||
const projectSignature = useMemo(() => {
|
||||
if (!videoPath) return null;
|
||||
@ -196,7 +200,11 @@ export default function App() {
|
||||
|
||||
useKeyboardShortcuts();
|
||||
|
||||
// Handle Escape key to exit timeline zone modes
|
||||
useEffect(() => {
|
||||
useLicenseStore.getState().checkStatus();
|
||||
}, []);
|
||||
|
||||
// Handle Escape key to exit timeline zone modes and close menus
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
@ -204,9 +212,9 @@ export default function App() {
|
||||
setMuteMode(false);
|
||||
setGainMode(false);
|
||||
setSpeedMode(false);
|
||||
setShowFileMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
@ -287,7 +295,9 @@ export default function App() {
|
||||
setProjectFilePath(null);
|
||||
setProjectName(null);
|
||||
loadVideo(path);
|
||||
await transcribeVideo(path);
|
||||
if (canEdit) {
|
||||
await transcribeVideo(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -562,44 +572,40 @@ export default function App() {
|
||||
{/* Top bar */}
|
||||
<header className="h-12 flex items-center px-4 border-b border-editor-border shrink-0">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ToolbarButton
|
||||
icon={<FilePlus2 className="w-4 h-4" />}
|
||||
label="New"
|
||||
onClick={handleNewProject}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<FolderOpen className="w-4 h-4" />}
|
||||
label="Open"
|
||||
onClick={handleOpenFile}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
label="Save"
|
||||
onClick={handleSaveProject}
|
||||
disabled={words.length === 0}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
label="Save As"
|
||||
onClick={handleSaveProjectAs}
|
||||
disabled={words.length === 0}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<FileInput className="w-4 h-4" />}
|
||||
label="Load"
|
||||
onClick={handleLoadProject}
|
||||
/>
|
||||
<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">
|
||||
<DropdownItem icon={<FilePlus2 className="w-4 h-4" />} label="New Project" onClick={() => { setShowFileMenu(false); handleNewProject(); }} />
|
||||
<DropdownItem icon={<FolderOpen className="w-4 h-4" />} label="Open File" onClick={() => { setShowFileMenu(false); handleOpenFile(); }} />
|
||||
<DropdownItem icon={<FileInput className="w-4 h-4" />} label="Load Project" onClick={() => { setShowFileMenu(false); handleLoadProject(); }} />
|
||||
<div className="border-t border-editor-border my-1" />
|
||||
<DropdownItem icon={<Save className="w-4 h-4" />} label="Save" onClick={() => { setShowFileMenu(false); handleSaveProject(); }} disabled={words.length === 0} />
|
||||
<DropdownItem icon={<Save className="w-4 h-4" />} label="Save As" onClick={() => { setShowFileMenu(false); handleSaveProjectAs(); }} disabled={words.length === 0} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ToolbarButton
|
||||
icon={<Scissors className="w-4 h-4" />}
|
||||
label="Cut"
|
||||
onClick={handleCut}
|
||||
active={cutMode}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<VolumeX className="w-4 h-4" />}
|
||||
label="Mute"
|
||||
onClick={handleMute}
|
||||
active={muteMode}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
@ -607,6 +613,7 @@ export default function App() {
|
||||
label="Gain Zone"
|
||||
onClick={handleGain}
|
||||
active={gainMode}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
@ -617,6 +624,7 @@ export default function App() {
|
||||
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"
|
||||
title="Gain dB for new gain zones"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@ -625,6 +633,7 @@ export default function App() {
|
||||
label="Speed Zone"
|
||||
onClick={handleSpeed}
|
||||
active={speedMode}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
@ -635,6 +644,7 @@ export default function App() {
|
||||
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"
|
||||
title="Playback rate for new speed zones"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<ToolbarButton
|
||||
@ -642,35 +652,35 @@ export default function App() {
|
||||
label="Zones"
|
||||
active={activePanel === 'zones'}
|
||||
onClick={() => togglePanel('zones')}
|
||||
disabled={!videoPath}
|
||||
disabled={!videoPath || !canEdit}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<span className="text-[10px] font-semibold">PA</span>}
|
||||
label="Pause Trim"
|
||||
active={activePanel === 'silence'}
|
||||
onClick={() => togglePanel('silence')}
|
||||
disabled={!videoPath}
|
||||
disabled={!videoPath || !canEdit}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<MapPin className="w-4 h-4" />}
|
||||
label="Markers"
|
||||
active={activePanel === 'markers'}
|
||||
onClick={() => togglePanel('markers')}
|
||||
disabled={!videoPath}
|
||||
disabled={!videoPath || !canEdit}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Music className="w-4 h-4" />}
|
||||
label="Music"
|
||||
active={activePanel === 'music'}
|
||||
onClick={() => togglePanel('music')}
|
||||
disabled={!videoPath}
|
||||
disabled={!videoPath || !canEdit}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<ListVideo className="w-4 h-4" />}
|
||||
label="Append"
|
||||
active={activePanel === 'append'}
|
||||
onClick={() => togglePanel('append')}
|
||||
disabled={!videoPath}
|
||||
disabled={!videoPath || !canEdit}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border">
|
||||
<select
|
||||
@ -700,7 +710,7 @@ export default function App() {
|
||||
</select>
|
||||
<button
|
||||
onClick={handleReprocessProject}
|
||||
disabled={isTranscribing || !videoPath}
|
||||
disabled={isTranscribing || !videoPath || !canEdit}
|
||||
title="Reprocess transcript with selected model"
|
||||
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"
|
||||
>
|
||||
@ -713,7 +723,7 @@ export default function App() {
|
||||
label="AI"
|
||||
active={activePanel === 'ai'}
|
||||
onClick={() => togglePanel('ai')}
|
||||
disabled={words.length === 0}
|
||||
disabled={words.length === 0 || !canEdit}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Download className="w-4 h-4" />}
|
||||
@ -841,6 +851,8 @@ export default function App() {
|
||||
</div>
|
||||
{import.meta.env.DEV && <DevPanel />}
|
||||
|
||||
<LicenseDialog />
|
||||
|
||||
{showReprocessConfirm && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
|
||||
@ -941,3 +953,30 @@ function ToolbarButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownItem({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
207
frontend/src/components/LicenseDialog.tsx
Normal file
207
frontend/src/components/LicenseDialog.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
import { useState } from 'react';
|
||||
import { useLicenseStore } from '../store/licenseStore';
|
||||
import { Key, Check, X, Loader2, Shield, Clock, AlertTriangle } from 'lucide-react';
|
||||
|
||||
export default function LicenseDialog() {
|
||||
const { status, showDialog, setShowDialog, activateLicense } = useLicenseStore();
|
||||
const [key, setKey] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activating, setActivating] = useState(false);
|
||||
|
||||
const handleActivate = async () => {
|
||||
if (!key.trim()) return;
|
||||
setActivating(true);
|
||||
setError(null);
|
||||
|
||||
const ok = await activateLicense(key.trim());
|
||||
if (!ok) {
|
||||
setError('Invalid license key. Check it was entered correctly.');
|
||||
}
|
||||
setActivating(false);
|
||||
};
|
||||
|
||||
const formatDate = (ts: number) => {
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
if (status.tag === 'Licensed') {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-editor-surface border border-editor-border shadow-lg text-xs">
|
||||
<Shield className="w-3.5 h-3.5 text-editor-success" />
|
||||
<span className="text-editor-text-muted">
|
||||
{status.license.tier === 'business' ? 'Business' : 'Pro'} — {status.license.customer_email}
|
||||
</span>
|
||||
<span className="text-editor-text-muted/50">
|
||||
expires {formatDate(status.license.expires_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.tag === 'Trial') {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-4 right-4 z-50">
|
||||
<button
|
||||
onClick={() => setShowDialog(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-editor-surface border border-editor-border shadow-lg text-xs hover:bg-editor-bg transition-colors"
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5 text-editor-accent" />
|
||||
<span className="text-editor-text-muted">
|
||||
Trial — {status.days_remaining} day{status.days_remaining !== 1 ? 's' : ''} left
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDialog && (
|
||||
<LicenseActivateDialog
|
||||
onClose={() => setShowDialog(false)}
|
||||
onActivate={handleActivate}
|
||||
keyValue={key}
|
||||
setKeyValue={setKey}
|
||||
error={error}
|
||||
activating={activating}
|
||||
trialEnding={status.days_remaining <= 3}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Expired — show banner + activation dialog (both dismissible)
|
||||
return (
|
||||
<>
|
||||
<ExpiredBanner onActivate={() => setShowDialog(true)} />
|
||||
|
||||
{showDialog && (
|
||||
<LicenseActivateDialog
|
||||
onClose={() => setShowDialog(false)}
|
||||
onActivate={handleActivate}
|
||||
keyValue={key}
|
||||
setKeyValue={setKey}
|
||||
error={error}
|
||||
activating={activating}
|
||||
expired
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Persistent top banner shown when trial expired — still allows export and loading */
|
||||
function ExpiredBanner({ onActivate }: { onActivate: () => void }) {
|
||||
return (
|
||||
<div className="h-9 flex items-center justify-center gap-3 px-4 bg-red-500/15 border-b 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 — export and project loading still work.
|
||||
<button onClick={onActivate} className="underline font-medium hover:text-red-200">
|
||||
Activate license
|
||||
</button>
|
||||
to restore editing.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LicenseActivateDialog({
|
||||
onClose,
|
||||
onActivate,
|
||||
keyValue,
|
||||
setKeyValue,
|
||||
error,
|
||||
activating,
|
||||
trialEnding,
|
||||
expired,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onActivate: () => void;
|
||||
keyValue: string;
|
||||
setKeyValue: (v: string) => void;
|
||||
error: string | null;
|
||||
activating: boolean;
|
||||
trialEnding?: boolean;
|
||||
expired?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 px-4">
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-6 space-y-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5 text-editor-accent" />
|
||||
<h3 className="text-sm font-semibold">
|
||||
{expired ? 'Trial Expired' : 'Activate TalkEdit'}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-editor-surface text-editor-text-muted"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expired && (
|
||||
<div className="text-xs text-editor-text-muted leading-relaxed space-y-1">
|
||||
<p className="font-medium text-red-300">Your 30-day trial has ended.</p>
|
||||
<p>
|
||||
You can still <strong>export videos</strong> and <strong>load projects</strong>.
|
||||
Enter a license key to restore editing, AI tools, and all other features.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trialEnding && !expired && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-amber-300">Your trial ends soon. Activate now to keep using all features.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!expired && !trialEnding && (
|
||||
<p className="text-xs text-editor-text-muted leading-relaxed">
|
||||
Enter your license key to activate TalkEdit Pro or Business.
|
||||
You received this key by email after purchase.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-editor-text-muted font-medium">License Key</label>
|
||||
<textarea
|
||||
value={keyValue}
|
||||
onChange={(e) => { setKeyValue(e.target.value); }}
|
||||
placeholder="talkedit_v1_..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-xs font-mono bg-editor-surface border border-editor-border rounded-lg text-editor-text placeholder:text-editor-text-muted/50 focus:outline-none focus:border-editor-accent resize-none"
|
||||
/>
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onActivate}
|
||||
disabled={activating || !keyValue.trim()}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{activating ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-4 h-4" />
|
||||
)}
|
||||
Activate
|
||||
</button>
|
||||
|
||||
<p className="text-[10px] text-editor-text-muted text-center">
|
||||
No license? <a href="#" className="text-editor-accent hover:underline">Purchase at talked.it</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef, useEffect, useMemo, useState } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { useLicenseStore } from '../store/licenseStore';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw, Search, ChevronUp, ChevronDown, X, RefreshCw } from 'lucide-react';
|
||||
|
||||
@ -42,6 +43,7 @@ export default function TranscriptEditor({
|
||||
const addGainRange = useEditorStore((s) => s.addGainRange);
|
||||
const addSpeedRange = useEditorStore((s) => s.addSpeedRange);
|
||||
const getWordAtTime = useEditorStore((s) => s.getWordAtTime);
|
||||
const canEdit = useLicenseStore((s) => s.canEdit);
|
||||
|
||||
const selectionStart = useRef<number | null>(null);
|
||||
const wasDragging = useRef(false);
|
||||
@ -206,7 +208,7 @@ export default function TranscriptEditor({
|
||||
if (zoneDragStart.current !== null && zoneDragRange) {
|
||||
const startWord = words[zoneDragRange.start];
|
||||
const endWord = words[zoneDragRange.end];
|
||||
if (startWord && endWord) {
|
||||
if (startWord && endWord && canEdit) {
|
||||
if (cutMode) addCutRange(startWord.start, endWord.end);
|
||||
if (muteMode) addMuteRange(startWord.start, endWord.end);
|
||||
if (gainMode) addGainRange(startWord.start, endWord.end, gainModeDb);
|
||||
@ -216,7 +218,7 @@ export default function TranscriptEditor({
|
||||
zoneDragStart.current = null;
|
||||
setZoneDragRange(null);
|
||||
selectionStart.current = null;
|
||||
}, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, addCutRange, addMuteRange, addGainRange, addSpeedRange]);
|
||||
}, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, addCutRange, addMuteRange, addGainRange, addSpeedRange, canEdit]);
|
||||
|
||||
const handleClickOutside = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@ -303,8 +305,9 @@ export default function TranscriptEditor({
|
||||
|
||||
const handleWordDoubleClick = useCallback((index: number) => {
|
||||
if (cutMode || muteMode || gainMode || speedMode) return;
|
||||
if (!canEdit) return;
|
||||
startEditing(index);
|
||||
}, [cutMode, muteMode, gainMode, speedMode, startEditing]);
|
||||
}, [cutMode, muteMode, gainMode, speedMode, startEditing, canEdit]);
|
||||
|
||||
// Focus edit input when it appears
|
||||
useEffect(() => {
|
||||
@ -556,35 +559,39 @@ export default function TranscriptEditor({
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={cutSelectedWords}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 text-red-300 rounded hover:bg-red-500/30 transition-colors"
|
||||
disabled={!canEdit}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 text-red-300 rounded hover:bg-red-500/30 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Scissors className="w-3 h-3" />
|
||||
Cut
|
||||
</button>
|
||||
<button
|
||||
onClick={muteSelectedWords}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-500/20 text-blue-300 rounded hover:bg-blue-500/30 transition-colors"
|
||||
disabled={!canEdit}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-500/20 text-blue-300 rounded hover:bg-blue-500/30 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<VolumeX className="w-3 h-3" />
|
||||
Mute
|
||||
</button>
|
||||
<button
|
||||
onClick={gainSelectedWords}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-amber-500/20 text-amber-300 rounded hover:bg-amber-500/30 transition-colors"
|
||||
disabled={!canEdit}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-amber-500/20 text-amber-300 rounded hover:bg-amber-500/30 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<SlidersHorizontal className="w-3 h-3" />
|
||||
Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB)
|
||||
</button>
|
||||
<button
|
||||
onClick={speedSelectedWords}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-emerald-500/20 text-emerald-300 rounded hover:bg-emerald-500/30 transition-colors"
|
||||
disabled={!canEdit}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-emerald-500/20 text-emerald-300 rounded hover:bg-emerald-500/30 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Gauge className="w-3 h-3" />
|
||||
Speed {speedModeValue.toFixed(2)}x
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReTranscribe}
|
||||
disabled={isReTranscribing}
|
||||
disabled={isReTranscribing || !canEdit}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-purple-500/20 text-purple-300 rounded hover:bg-purple-500/30 disabled:opacity-40 transition-colors"
|
||||
title="Re-run Whisper transcription on this segment"
|
||||
>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { useLicenseStore } from '../store/licenseStore';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { extractThumbnails } from '../lib/thumbnails';
|
||||
|
||||
@ -264,6 +265,7 @@ export default function WaveformTimeline({
|
||||
const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
|
||||
const removeGainRange = useEditorStore((s) => s.removeGainRange);
|
||||
const removeSpeedRange = useEditorStore((s) => s.removeSpeedRange);
|
||||
const canEdit = useLicenseStore((s) => s.canEdit);
|
||||
|
||||
const waveformDataRef = useRef<WaveformData | null>(null);
|
||||
const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in
|
||||
@ -1025,7 +1027,7 @@ export default function WaveformTimeline({
|
||||
|
||||
// Check if clicking on a zone
|
||||
const zoneHit = getZoneAtPosition(e.clientX, e.clientY);
|
||||
if (zoneHit) {
|
||||
if (zoneHit && canEdit) {
|
||||
if (zoneHit.edge === 'move') {
|
||||
setSelectedZone({ type: zoneHit.type, id: zoneHit.id });
|
||||
} else {
|
||||
@ -1098,7 +1100,7 @@ export default function WaveformTimeline({
|
||||
// Clear selection if clicking elsewhere
|
||||
setSelectedZone(null);
|
||||
|
||||
if (cutMode || muteMode || gainMode || speedMode) {
|
||||
if (canEdit && (cutMode || muteMode || gainMode || speedMode)) {
|
||||
// Range selection mode
|
||||
const startTime = clientXToTime(e.clientX);
|
||||
selectionStartRef.current = startTime;
|
||||
@ -1181,7 +1183,7 @@ export default function WaveformTimeline({
|
||||
if (e.key === 'Escape') {
|
||||
setSelectedZone(null);
|
||||
editingZoneRef.current = null;
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
} else if (canEdit && (e.key === 'Delete' || e.key === 'Backspace')) {
|
||||
if (selectedZone) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@ -1204,7 +1206,7 @@ export default function WaveformTimeline({
|
||||
// Capture phase ensures zone delete runs before app-level bubble shortcuts.
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
}, [selectedZone, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange]);
|
||||
}, [selectedZone, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, canEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedZone) return;
|
||||
|
||||
@ -96,4 +96,20 @@ window.electronAPI = {
|
||||
await writeTextFile(path, content);
|
||||
return true;
|
||||
},
|
||||
|
||||
activateLicense: (key: string): Promise<any> => {
|
||||
return invoke('activate_license', { licenseKey: key });
|
||||
},
|
||||
|
||||
getAppStatus: (): Promise<any> => {
|
||||
return invoke('get_app_status');
|
||||
},
|
||||
|
||||
deactivateLicense: (): Promise<void> => {
|
||||
return invoke('deactivate_license');
|
||||
},
|
||||
|
||||
hasLicenseFeature: (feature: string): Promise<boolean> => {
|
||||
return invoke('has_license_feature', { feature });
|
||||
},
|
||||
};
|
||||
|
||||
105
frontend/src/store/licenseStore.ts
Normal file
105
frontend/src/store/licenseStore.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface LicensePayload {
|
||||
license_id: string;
|
||||
customer_email: string;
|
||||
tier: 'pro' | 'business';
|
||||
features: string[];
|
||||
issued_at: number;
|
||||
expires_at: number;
|
||||
max_activations: number;
|
||||
}
|
||||
|
||||
export interface TrialState {
|
||||
started_at: number;
|
||||
}
|
||||
|
||||
export type AppStatus =
|
||||
| { tag: 'Licensed'; license: LicensePayload }
|
||||
| { tag: 'Trial'; days_remaining: number; started_at: number }
|
||||
| { tag: 'Expired' };
|
||||
|
||||
interface LicenseState {
|
||||
status: AppStatus | null;
|
||||
isLoaded: boolean;
|
||||
showDialog: boolean;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
interface LicenseActions {
|
||||
setStatus: (status: AppStatus | null) => void;
|
||||
setShowDialog: (show: boolean) => void;
|
||||
checkStatus: () => Promise<void>;
|
||||
activateLicense: (key: string) => Promise<boolean>;
|
||||
deactivateLicense: () => Promise<void>;
|
||||
hasFeature: (feature: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const useLicenseStore = create<LicenseState & LicenseActions>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
status: null,
|
||||
isLoaded: false,
|
||||
showDialog: false,
|
||||
canEdit: true,
|
||||
|
||||
setStatus: (status) => {
|
||||
const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial';
|
||||
set({ status, isLoaded: true, canEdit });
|
||||
},
|
||||
|
||||
setShowDialog: (show) => set({ showDialog: show }),
|
||||
|
||||
checkStatus: async () => {
|
||||
try {
|
||||
const status = await window.electronAPI?.getAppStatus();
|
||||
const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial';
|
||||
set({ status: status || { tag: 'Expired' }, isLoaded: true, canEdit });
|
||||
} catch {
|
||||
set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false });
|
||||
}
|
||||
},
|
||||
|
||||
activateLicense: async (key: string): Promise<boolean> => {
|
||||
try {
|
||||
const license = await window.electronAPI?.activateLicense(key);
|
||||
if (!license) return false;
|
||||
set({ status: { tag: 'Licensed', license }, showDialog: false, canEdit: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
deactivateLicense: async () => {
|
||||
try {
|
||||
await window.electronAPI?.deactivateLicense();
|
||||
const s = await window.electronAPI?.getAppStatus();
|
||||
const canEdit = s?.tag === 'Licensed' || s?.tag === 'Trial';
|
||||
set({ status: s || { tag: 'Expired' }, isLoaded: true, canEdit });
|
||||
} catch {
|
||||
set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false });
|
||||
}
|
||||
},
|
||||
|
||||
hasFeature: async (feature: string): Promise<boolean> => {
|
||||
try {
|
||||
return await window.electronAPI?.hasLicenseFeature(feature) || false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'talkedit-license',
|
||||
partialize: (state) => {
|
||||
// Only persist Licensed status (trial is ephemeral)
|
||||
if (state.status?.tag === 'Licensed') {
|
||||
return { status: state.status };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
4
frontend/src/vite-env.d.ts
vendored
4
frontend/src/vite-env.d.ts
vendored
@ -20,6 +20,10 @@ interface DesktopAPI {
|
||||
transcribe: (filePath: string, modelName: string, language?: string) => Promise<any>;
|
||||
readFile: (path: string) => Promise<string>;
|
||||
writeFile: (path: string, content: string) => Promise<boolean>;
|
||||
activateLicense: (key: string) => Promise<any>;
|
||||
getAppStatus: () => Promise<any>;
|
||||
deactivateLicense: () => Promise<void>;
|
||||
hasLicenseFeature: (feature: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/AppendClipPanel.tsx","./src/components/BackgroundMusicPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/MarkersPanel.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/VolumePanel.tsx","./src/components/WaveformTimeline.tsx","./src/components/ZoneEditor.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/keybindings.ts","./src/lib/tauri-bridge.ts","./src/lib/thumbnails.ts","./src/store/aiStore.ts","./src/store/editorStore.test.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/AppendClipPanel.tsx","./src/components/BackgroundMusicPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/LicenseDialog.tsx","./src/components/MarkersPanel.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/VolumePanel.tsx","./src/components/WaveformTimeline.tsx","./src/components/ZoneEditor.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/keybindings.ts","./src/lib/tauri-bridge.ts","./src/lib/thumbnails.ts","./src/store/aiStore.ts","./src/store/editorStore.test.ts","./src/store/editorStore.ts","./src/store/licenseStore.ts","./src/types/project.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user