Gate AI editing features behind license (trial users no longer get AI editing)

This commit is contained in:
2026-05-06 23:21:45 -06:00
parent 850b373d42
commit 8bd1ad5b69
3 changed files with 219 additions and 151 deletions

View File

@ -1,7 +1,8 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { useAIStore } from '../store/aiStore'; import { useAIStore } from '../store/aiStore';
import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download, RotateCcw, RefreshCw } from 'lucide-react'; import { useLicenseStore } from '../store/licenseStore';
import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download, RotateCcw, RefreshCw, Lock } from 'lucide-react';
import type { ClipSuggestion } from '../types/project'; import type { ClipSuggestion } from '../types/project';
interface AIPanelProps { interface AIPanelProps {
@ -12,6 +13,8 @@ interface AIPanelProps {
export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }: AIPanelProps) { export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }: AIPanelProps) {
const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime } = useEditorStore(); const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime } = useEditorStore();
const canUseAI = useLicenseStore((s) => s.canUseAI);
const setShowLicenseDialog = useLicenseStore((s) => s.setShowDialog);
const { const {
defaultProvider, defaultProvider,
providers, providers,
@ -188,169 +191,205 @@ export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }:
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{activeTab === 'filler' && ( {activeTab === 'filler' && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-editor-text-muted"> {!canUseAI ? (
Use AI to detect and remove filler words like "um", "uh", "like", "you know" from <div className="text-center py-8 px-4">
your transcript. <Lock className="w-8 h-8 text-editor-text-muted mx-auto mb-3" />
</p> <p className="text-sm font-medium mb-1">AI editing requires a license</p>
<div className="space-y-1.5"> <p className="text-xs text-editor-text-muted mb-4">
<label className="text-[11px] text-editor-text-muted font-medium"> Upgrade to Pro or Business to unlock filler word removal, clip suggestions, and more.
Custom filler words (comma-separated) </p>
</label>
<input
type="text"
value={customFillerWords}
onChange={(e) => setCustomFillerWords(e.target.value)}
placeholder="e.g. okay, alright, anyway"
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
/>
</div>
<button
onClick={detectFillers}
disabled={isProcessing || words.length === 0}
title="Scan the entire transcript for filler words (um, uh, like, you know) and mark for removal"
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-40 rounded-lg text-sm font-medium transition-colors"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{processingMessage}
</>
) : (
<>
<Sparkles className="w-4 h-4" />
Detect Filler Words
</>
)}
</button>
{error && (
<div className="bg-red-500/10 border border-red-500/40 rounded text-xs text-red-300 p-2 flex items-center justify-between">
<span>{error}</span>
<button <button
onClick={detectFillers} onClick={() => setShowLicenseDialog(true)}
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 rounded transition-colors shrink-0 ml-2" className="px-4 py-2 bg-editor-accent hover:bg-editor-accent-hover text-white rounded-lg text-sm font-medium transition-colors"
> >
<RotateCcw className="w-3 h-3" /> Retry Upgrade Now
</button> </button>
</div> </div>
)} ) : (
{fillerResult && fillerResult.fillerWords.length > 0 && ( <>
<div className="space-y-3"> <p className="text-xs text-editor-text-muted">
<div className="flex items-center justify-between"> Use AI to detect and remove filler words like "um", "uh", "like", "you know" from
<span className="text-xs font-medium"> your transcript.
Found {fillerResult.fillerWords.length} filler words </p>
</span> <div className="space-y-1.5">
<div className="flex gap-1"> <label className="text-[11px] text-editor-text-muted font-medium">
Custom filler words (comma-separated)
</label>
<input
type="text"
value={customFillerWords}
onChange={(e) => setCustomFillerWords(e.target.value)}
placeholder="e.g. okay, alright, anyway"
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
/>
</div>
<button
onClick={detectFillers}
disabled={isProcessing || words.length === 0}
title="Scan the entire transcript for filler words (um, uh, like, you know) and mark for removal"
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-40 rounded-lg text-sm font-medium transition-colors"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{processingMessage}
</>
) : (
<>
<Sparkles className="w-4 h-4" />
Detect Filler Words
</>
)}
</button>
{error && (
<div className="bg-red-500/10 border border-red-500/40 rounded text-xs text-red-300 p-2 flex items-center justify-between">
<span>{error}</span>
<button <button
onClick={applyFillerDeletions} onClick={detectFillers}
title="Create cut ranges for all detected filler words at once" className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 rounded transition-colors shrink-0 ml-2"
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30"
> >
<Check className="w-3 h-3" /> Apply All <RotateCcw className="w-3 h-3" /> Retry
</button>
<button
onClick={() => { setFillerResult(null); setError(null); }}
title="Clear detected filler word results without applying"
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-border text-editor-text-muted rounded hover:bg-editor-surface"
>
<X className="w-3 h-3" /> Dismiss
</button> </button>
</div> </div>
</div> )}
<div className="space-y-1 max-h-64 overflow-y-auto"> {fillerResult && fillerResult.fillerWords.length > 0 && (
{fillerResult.fillerWords.map((fw) => ( <div className="space-y-3">
<div <div className="flex items-center justify-between">
key={fw.index} <span className="text-xs font-medium">
className="flex items-center justify-between px-2 py-1.5 bg-editor-word-filler rounded text-xs" Found {fillerResult.fillerWords.length} filler words
>
<span>
<strong>"{fw.word}"</strong>
<span className="text-editor-text-muted ml-1"> {fw.reason}</span>
</span> </span>
<div className="flex gap-1">
<button
onClick={applyFillerDeletions}
title="Create cut ranges for all detected filler words at once"
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30"
>
<Check className="w-3 h-3" /> Apply All
</button>
<button
onClick={() => { setFillerResult(null); setError(null); }}
title="Clear detected filler word results without applying"
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-border text-editor-text-muted rounded hover:bg-editor-surface"
>
<X className="w-3 h-3" /> Dismiss
</button>
</div>
</div> </div>
))} <div className="space-y-1 max-h-64 overflow-y-auto">
</div> {fillerResult.fillerWords.map((fw) => (
</div> <div
)} key={fw.index}
className="flex items-center justify-between px-2 py-1.5 bg-editor-word-filler rounded text-xs"
>
<span>
<strong>"{fw.word}"</strong>
<span className="text-editor-text-muted ml-1"> {fw.reason}</span>
</span>
</div>
))}
</div>
</div>
)}
{fillerResult && fillerResult.fillerWords.length === 0 && ( {fillerResult && fillerResult.fillerWords.length === 0 && (
<p className="text-xs text-editor-success">No filler words detected.</p> <p className="text-xs text-editor-success">No filler words detected.</p>
)}
</>
)} )}
</div> </div>
)} )}
{activeTab === 'clips' && ( {activeTab === 'clips' && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-editor-text-muted"> {!canUseAI ? (
AI analyzes your transcript and suggests the most engaging segments for a <div className="text-center py-8 px-4">
YouTube Short or social media clip. <Lock className="w-8 h-8 text-editor-text-muted mx-auto mb-3" />
</p> <p className="text-sm font-medium mb-1">AI clip suggestions require a license</p>
<button <p className="text-xs text-editor-text-muted mb-4">
onClick={createClips} Upgrade to Pro or Business to find the best segments for social media clips.
disabled={isProcessing || words.length === 0} </p>
title="Analyze transcript to find the most engaging 20-60 second segments for social media"
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-40 rounded-lg text-sm font-medium transition-colors"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{processingMessage}
</>
) : (
<>
<Film className="w-4 h-4" />
Find Best Clips
</>
)}
</button>
{error && (
<div className="bg-red-500/10 border border-red-500/40 rounded text-xs text-red-300 p-2 flex items-center justify-between">
<span>{error}</span>
<button <button
onClick={createClips} onClick={() => setShowLicenseDialog(true)}
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 rounded transition-colors shrink-0 ml-2" className="px-4 py-2 bg-editor-accent hover:bg-editor-accent-hover text-white rounded-lg text-sm font-medium transition-colors"
> >
<RotateCcw className="w-3 h-3" /> Retry Upgrade Now
</button> </button>
</div> </div>
)} ) : (
{clipSuggestions.length > 0 && ( <>
<div className="space-y-3"> <p className="text-xs text-editor-text-muted">
{clipSuggestions.map((clip, i) => ( AI analyzes your transcript and suggests the most engaging segments for a
<div key={i} className="p-3 bg-editor-surface rounded-lg space-y-2"> YouTube Short or social media clip.
<div className="flex items-center justify-between"> </p>
<span className="text-xs font-semibold">{clip.title}</span> <button
<span className="text-[10px] text-editor-text-muted"> onClick={createClips}
{Math.round(clip.endTime - clip.startTime)}s disabled={isProcessing || words.length === 0}
</span> title="Analyze transcript to find the most engaging 20-60 second segments for social media"
</div> 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-40 rounded-lg text-sm font-medium transition-colors"
<p className="text-[11px] text-editor-text-muted">{clip.reason}</p> >
<div className="flex gap-2"> {isProcessing ? (
<button <>
onClick={() => handlePreviewClip(clip)} <Loader2 className="w-4 h-4 animate-spin" />
title="Seek to this clip's position and play a preview" {processingMessage}
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-accent/20 text-editor-accent rounded hover:bg-editor-accent/30 transition-colors" </>
> ) : (
<Play className="w-3 h-3" /> Preview <>
</button> <Film className="w-4 h-4" />
<button Find Best Clips
onClick={() => handleExportClip(clip, i)} </>
disabled={exportingClipIndex === i} )}
title="Export just this segment as a standalone video file" </button>
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30 disabled:opacity-40 transition-colors"
> {error && (
{exportingClipIndex === i ? ( <div className="bg-red-500/10 border border-red-500/40 rounded text-xs text-red-300 p-2 flex items-center justify-between">
<Loader2 className="w-3 h-3 animate-spin" /> <span>{error}</span>
) : ( <button
<Download className="w-3 h-3" /> onClick={createClips}
)} className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 rounded transition-colors shrink-0 ml-2"
Export >
</button> <RotateCcw className="w-3 h-3" /> Retry
</div> </button>
</div> </div>
))} )}
</div> {clipSuggestions.length > 0 && (
<div className="space-y-3">
{clipSuggestions.map((clip, i) => (
<div key={i} className="p-3 bg-editor-surface rounded-lg space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold">{clip.title}</span>
<span className="text-[10px] text-editor-text-muted">
{Math.round(clip.endTime - clip.startTime)}s
</span>
</div>
<p className="text-[11px] text-editor-text-muted">{clip.reason}</p>
<div className="flex gap-2">
<button
onClick={() => handlePreviewClip(clip)}
title="Seek to this clip's position and play a preview"
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-accent/20 text-editor-accent rounded hover:bg-editor-accent/30 transition-colors"
>
<Play className="w-3 h-3" /> Preview
</button>
<button
onClick={() => handleExportClip(clip, i)}
disabled={exportingClipIndex === i}
title="Export just this segment as a standalone video file"
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30 disabled:opacity-40 transition-colors"
>
{exportingClipIndex === i ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Export
</button>
</div>
</div>
))}
</div>
)}
</>
)} )}
</div> </div>
)} )}

View File

@ -16,7 +16,7 @@ function mockElectronAPI(overrides: Record<string, any> = {}) {
describe('licenseStore', () => { describe('licenseStore', () => {
beforeEach(() => { beforeEach(() => {
mockElectronAPI(); mockElectronAPI();
useLicenseStore.setState({ status: null, isLoaded: false, canEdit: true, showDialog: false }); useLicenseStore.setState({ status: null, isLoaded: false, canEdit: true, canUseAI: false, showDialog: false });
}); });
describe('canEdit', () => { describe('canEdit', () => {
@ -45,10 +45,28 @@ describe('licenseStore', () => {
}); });
test('is false when status is null', () => { test('is false when status is null', () => {
useLicenseStore.setState({ status: null, canEdit: true }); useLicenseStore.setState({ status: null, canEdit: true, canUseAI: false });
useLicenseStore.getState().setStatus(null); useLicenseStore.getState().setStatus(null);
expect(useLicenseStore.getState().canEdit).toBe(false); expect(useLicenseStore.getState().canEdit).toBe(false);
}); });
test('is true for Licensed status', () => {
useLicenseStore.getState().setStatus({ tag: 'Licensed', license: { license_id: 'x', tier: 'pro', customer_email: 'a@b.com', expires_at: 9999999999, features: [], issued_at: 1, max_activations: 1 } });
expect(useLicenseStore.getState().canEdit).toBe(true);
expect(useLicenseStore.getState().canUseAI).toBe(true);
});
test('is false for Trial status', () => {
useLicenseStore.getState().setStatus({ tag: 'Trial', days_remaining: 5, started_at: Date.now() });
expect(useLicenseStore.getState().canEdit).toBe(true);
expect(useLicenseStore.getState().canUseAI).toBe(false);
});
test('is false for Expired status', () => {
useLicenseStore.getState().setStatus({ tag: 'Expired' });
expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
});
}); });
describe('checkStatus', () => { describe('checkStatus', () => {
@ -76,6 +94,7 @@ describe('licenseStore', () => {
await useLicenseStore.getState().checkStatus(); await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().status?.tag).toBe('Expired'); expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false); expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
}); });
test('handles missing electronAPI', async () => { test('handles missing electronAPI', async () => {
@ -83,6 +102,7 @@ describe('licenseStore', () => {
await useLicenseStore.getState().checkStatus(); await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().status?.tag).toBe('Expired'); expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false); expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
}); });
test('sets isLoaded to true after check', async () => { test('sets isLoaded to true after check', async () => {
@ -99,6 +119,7 @@ describe('licenseStore', () => {
expect(result).toBe(true); expect(result).toBe(true);
expect(useLicenseStore.getState().status?.tag).toBe('Licensed'); expect(useLicenseStore.getState().status?.tag).toBe('Licensed');
expect(useLicenseStore.getState().canEdit).toBe(true); expect(useLicenseStore.getState().canEdit).toBe(true);
expect(useLicenseStore.getState().canUseAI).toBe(true);
}); });
test('returns false on invalid key', async () => { test('returns false on invalid key', async () => {
@ -131,6 +152,7 @@ describe('licenseStore', () => {
await useLicenseStore.getState().deactivateLicense(); await useLicenseStore.getState().deactivateLicense();
expect(useLicenseStore.getState().status?.tag).toBe('Expired'); expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false); expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
}); });
test('restores Trial when trial is still valid', async () => { test('restores Trial when trial is still valid', async () => {
@ -141,14 +163,16 @@ describe('licenseStore', () => {
await useLicenseStore.getState().deactivateLicense(); await useLicenseStore.getState().deactivateLicense();
expect(useLicenseStore.getState().status?.tag).toBe('Trial'); expect(useLicenseStore.getState().status?.tag).toBe('Trial');
expect(useLicenseStore.getState().canEdit).toBe(true); expect(useLicenseStore.getState().canEdit).toBe(true);
expect(useLicenseStore.getState().canUseAI).toBe(false);
}); });
test('handles API error', async () => { test('handles API error', async () => {
mockElectronAPI({ deactivateLicense: vi.fn().mockRejectedValue(new Error('fail')) }); mockElectronAPI({ deactivateLicense: vi.fn().mockRejectedValue(new Error('fail')) });
useLicenseStore.setState({ status: { tag: 'Licensed', license: {} as any }, canEdit: true }); useLicenseStore.setState({ status: { tag: 'Licensed', license: {} as any }, canEdit: true, canUseAI: true });
await useLicenseStore.getState().deactivateLicense(); await useLicenseStore.getState().deactivateLicense();
expect(useLicenseStore.getState().status?.tag).toBe('Expired'); expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false); expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
}); });
}); });

View File

@ -25,6 +25,7 @@ interface LicenseState {
isLoaded: boolean; isLoaded: boolean;
showDialog: boolean; showDialog: boolean;
canEdit: boolean; canEdit: boolean;
canUseAI: boolean;
} }
interface LicenseActions { interface LicenseActions {
@ -43,10 +44,12 @@ export const useLicenseStore = create<LicenseState & LicenseActions>()(
isLoaded: false, isLoaded: false,
showDialog: false, showDialog: false,
canEdit: false, canEdit: false,
canUseAI: false,
setStatus: (status) => { setStatus: (status) => {
const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial'; const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial';
set({ status, isLoaded: true, canEdit }); const canUseAI = status?.tag === 'Licensed';
set({ status, isLoaded: true, canEdit, canUseAI });
}, },
setShowDialog: (show) => set({ showDialog: show }), setShowDialog: (show) => set({ showDialog: show }),
@ -55,9 +58,10 @@ export const useLicenseStore = create<LicenseState & LicenseActions>()(
try { try {
const status = await window.electronAPI?.getAppStatus(); const status = await window.electronAPI?.getAppStatus();
const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial'; const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial';
set({ status: status || { tag: 'Expired' }, isLoaded: true, canEdit }); const canUseAI = status?.tag === 'Licensed';
set({ status: status || { tag: 'Expired' }, isLoaded: true, canEdit, canUseAI });
} catch { } catch {
set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false }); set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false, canUseAI: false });
} }
}, },
@ -65,7 +69,7 @@ export const useLicenseStore = create<LicenseState & LicenseActions>()(
try { try {
const license = await window.electronAPI?.activateLicense(key); const license = await window.electronAPI?.activateLicense(key);
if (!license) return false; if (!license) return false;
set({ status: { tag: 'Licensed', license }, showDialog: false, canEdit: true }); set({ status: { tag: 'Licensed', license }, showDialog: false, canEdit: true, canUseAI: true });
return true; return true;
} catch { } catch {
return false; return false;
@ -77,9 +81,10 @@ export const useLicenseStore = create<LicenseState & LicenseActions>()(
await window.electronAPI?.deactivateLicense(); await window.electronAPI?.deactivateLicense();
const s = await window.electronAPI?.getAppStatus(); const s = await window.electronAPI?.getAppStatus();
const canEdit = s?.tag === 'Licensed' || s?.tag === 'Trial'; const canEdit = s?.tag === 'Licensed' || s?.tag === 'Trial';
set({ status: s || { tag: 'Expired' }, isLoaded: true, canEdit }); const canUseAI = s?.tag === 'Licensed';
set({ status: s || { tag: 'Expired' }, isLoaded: true, canEdit, canUseAI });
} catch { } catch {
set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false }); set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false, canUseAI: false });
} }
}, },