mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-04 01:25:18 -07:00
@@ -99,6 +99,8 @@ jobs:
|
|||||||
- uses: tauri-apps/tauri-action@v0
|
- uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
with:
|
with:
|
||||||
projectPath: tauri
|
projectPath: tauri
|
||||||
tagName: v__VERSION__
|
tagName: v__VERSION__
|
||||||
@@ -111,6 +113,9 @@ jobs:
|
|||||||
- **macOS**: Download the `.dmg` file
|
- **macOS**: Download the `.dmg` file
|
||||||
- **Windows**: Download the `.msi` installer
|
- **Windows**: Download the `.msi` installer
|
||||||
- **Linux**: Download the `.AppImage` or `.deb` package
|
- **Linux**: Download the `.AppImage` or `.deb` package
|
||||||
|
|
||||||
|
The app includes automatic updates - future updates will be installed automatically.
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
|
includeUpdaterJson: true
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
ENV/
|
ENV/
|
||||||
|
*.prompt
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|||||||
+1
-1
@@ -437,7 +437,7 @@ projects
|
|||||||
|
|
||||||
## 💡 Development Workflow
|
## 💡 Development Workflow
|
||||||
|
|
||||||
1. **Start backend**: `bun run dev:backend` (or via Tauri)
|
1. **Start backend**: `bun run dev:server` (or via Tauri)
|
||||||
2. **Start frontend**: `bun run dev` (Tauri) or `bun run dev:web` (web)
|
2. **Start frontend**: `bun run dev` (Tauri) or `bun run dev:web` (web)
|
||||||
3. **Generate API client**: `bun run generate:api` (after backend changes)
|
3. **Generate API client**: `bun run generate:api` (after backend changes)
|
||||||
4. **Build server binary**: `bun run build:server` (for Tauri bundling)
|
4. **Build server binary**: `bun run build:server` (for Tauri bundling)
|
||||||
|
|||||||
+25
-21
@@ -13,34 +13,38 @@
|
|||||||
"check": "biome check --write src"
|
"check": "biome check --write src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
|
||||||
"react": "^18.3.0",
|
|
||||||
"react-dom": "^18.3.0",
|
|
||||||
"@tanstack/react-query": "^5.0.0",
|
|
||||||
"@tanstack/react-query-devtools": "^5.0.0",
|
|
||||||
"zustand": "^4.5.0",
|
|
||||||
"react-hook-form": "^7.53.0",
|
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"zod": "^3.23.8",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"wavesurfer.js": "^7.0.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"lucide-react": "^0.454.0",
|
|
||||||
"date-fns": "^3.6.0",
|
|
||||||
"class-variance-authority": "^0.7.0",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"tailwind-merge": "^2.5.4",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.1",
|
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.0",
|
|
||||||
"@radix-ui/react-toast": "^1.2.1",
|
|
||||||
"@radix-ui/react-popover": "^1.1.1",
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
"@radix-ui/react-progress": "^1.1.0",
|
"@radix-ui/react-progress": "^1.1.0",
|
||||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1"
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.0",
|
||||||
|
"@radix-ui/react-toast": "^1.2.1",
|
||||||
|
"@tanstack/react-query": "^5.0.0",
|
||||||
|
"@tanstack/react-query-devtools": "^5.0.0",
|
||||||
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"framer-motion": "^12.29.0",
|
||||||
|
"lucide-react": "^0.454.0",
|
||||||
|
"react": "^18.3.0",
|
||||||
|
"react-dom": "^18.3.0",
|
||||||
|
"react-hook-form": "^7.53.0",
|
||||||
|
"tailwind-merge": "^2.5.4",
|
||||||
|
"wavesurfer.js": "^7.0.0",
|
||||||
|
"zod": "^3.23.8",
|
||||||
|
"zustand": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
|||||||
+70
-34
@@ -3,48 +3,73 @@ import { GenerationForm } from '@/components/Generation/GenerationForm';
|
|||||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||||
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||||
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
||||||
|
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||||
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
||||||
import { Sidebar } from '@/components/Sidebar';
|
import { Sidebar } from '@/components/Sidebar';
|
||||||
import { isTauri, startServer, stopServer } from '@/lib/tauri';
|
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
||||||
|
import { UpdateNotification } from '@/components/UpdateNotification';
|
||||||
|
import { isTauri, startServer, setupWindowCloseHandler } from '@/lib/tauri';
|
||||||
|
|
||||||
// Track if server is starting to prevent duplicate starts
|
// Track if server is starting to prevent duplicate starts
|
||||||
let serverStarting = false;
|
let serverStarting = false;
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [activeTab, setActiveTab] = useState('profiles');
|
const [activeTab, setActiveTab] = useState('main');
|
||||||
const [serverReady, setServerReady] = useState(false);
|
const [serverReady, setServerReady] = useState(false);
|
||||||
|
|
||||||
// Auto-start server when running in Tauri
|
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri() || serverStarting) {
|
if (!isTauri()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup window close handler to check setting and stop server if needed
|
||||||
|
// This works in both dev and prod, but will only stop server if it was started by the app
|
||||||
|
setupWindowCloseHandler().catch((error) => {
|
||||||
|
console.error('Failed to setup window close handler:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only auto-start server in production mode
|
||||||
|
// In dev mode, user runs server separately
|
||||||
|
if (!import.meta.env?.PROD) {
|
||||||
|
console.log('Dev mode: Skipping auto-start of server (run it separately)');
|
||||||
|
setServerReady(true); // Mark as ready so UI doesn't show loading screen
|
||||||
|
// Mark that server was not started by app (so we don't try to stop it on close)
|
||||||
|
// @ts-expect-error - adding property to window
|
||||||
|
window.__voiceboxServerStartedByApp = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-start server in production
|
||||||
|
if (serverStarting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
serverStarting = true;
|
serverStarting = true;
|
||||||
console.log('Running in Tauri, starting bundled server...');
|
console.log('Production mode: Starting bundled server...');
|
||||||
|
|
||||||
startServer(false)
|
startServer(false)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Server is ready');
|
console.log('Server is ready');
|
||||||
setServerReady(true);
|
setServerReady(true);
|
||||||
|
// Mark that we started the server (so we know to stop it on close)
|
||||||
|
// @ts-expect-error - adding property to window
|
||||||
|
window.__voiceboxServerStartedByApp = true;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to auto-start server:', error);
|
console.error('Failed to auto-start server:', error);
|
||||||
serverStarting = false;
|
serverStarting = false;
|
||||||
|
// @ts-expect-error - adding property to window
|
||||||
|
window.__voiceboxServerStartedByApp = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup: stop server on actual unmount (not StrictMode remount)
|
// Cleanup: stop server on actual unmount (not StrictMode remount)
|
||||||
|
// Note: Window close is handled separately in Tauri Rust code
|
||||||
return () => {
|
return () => {
|
||||||
// In production builds, we want to stop the server on unmount
|
// Window close event handles server shutdown based on setting
|
||||||
// In dev mode, React StrictMode causes remounts, so we skip cleanup
|
|
||||||
if (import.meta.env?.PROD) {
|
|
||||||
stopServer().catch((error) => {
|
|
||||||
console.error('Failed to stop server on cleanup:', error);
|
|
||||||
});
|
|
||||||
serverStarting = false;
|
serverStarting = false;
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -61,40 +86,51 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex">
|
<div className="h-screen bg-background flex flex-col overflow-hidden">
|
||||||
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
|
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
|
|
||||||
<main className="flex-1 ml-20">
|
<main className="flex-1 ml-20 overflow-hidden flex flex-col">
|
||||||
<div className="container mx-auto px-8 py-8 max-w-7xl">
|
<div className="container mx-auto px-8 py-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
|
||||||
{activeTab === 'profiles' && (
|
<UpdateNotification />
|
||||||
<div className="space-y-4">
|
|
||||||
<ProfileList />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'generate' && (
|
{activeTab === 'settings' ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 overflow-y-auto">
|
||||||
<GenerationForm />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'history' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<HistoryTable />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'settings' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<ConnectionForm />
|
<ConnectionForm />
|
||||||
<ServerStatus />
|
<ServerStatus />
|
||||||
</div>
|
</div>
|
||||||
|
{isTauri() && <UpdateStatus />}
|
||||||
<ModelManagement />
|
<ModelManagement />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
// Main view: Profiles top left, Generator bottom left, History right
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden">
|
||||||
|
{/* Left Column */}
|
||||||
|
<div className="flex flex-col gap-6 min-h-0 overflow-y-auto pb-32">
|
||||||
|
{/* Profiles - Top Left */}
|
||||||
|
<div className="shrink-0 flex flex-col">
|
||||||
|
<ProfileList />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generator - Bottom Left */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<GenerationForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - History */}
|
||||||
|
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||||
|
<HistoryTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audio Player - always visible except on settings */}
|
||||||
|
{activeTab !== 'settings' && <AudioPlayer />}
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
@@ -0,0 +1,477 @@
|
|||||||
|
import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import WaveSurfer from 'wavesurfer.js';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
|
export function AudioPlayer() {
|
||||||
|
const {
|
||||||
|
audioUrl,
|
||||||
|
title,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
volume,
|
||||||
|
isLooping,
|
||||||
|
setIsPlaying,
|
||||||
|
setCurrentTime,
|
||||||
|
setDuration,
|
||||||
|
setVolume,
|
||||||
|
toggleLoop,
|
||||||
|
} = usePlayerStore();
|
||||||
|
|
||||||
|
const waveformRef = useRef<HTMLDivElement>(null);
|
||||||
|
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Initialize WaveSurfer (only when audioUrl exists and container is ready)
|
||||||
|
useEffect(() => {
|
||||||
|
// Don't initialize if no audioUrl or already initialized
|
||||||
|
if (!audioUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wavesurferRef.current) {
|
||||||
|
console.log('WaveSurfer already initialized, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Creating NEW WaveSurfer instance');
|
||||||
|
|
||||||
|
// Wait for container to be properly rendered
|
||||||
|
const initWaveSurfer = () => {
|
||||||
|
const container = waveformRef.current;
|
||||||
|
if (!container) {
|
||||||
|
// Container not ready yet, retry
|
||||||
|
setTimeout(initWaveSurfer, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if container has dimensions and is visible
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const style = window.getComputedStyle(container);
|
||||||
|
const isVisible =
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0 &&
|
||||||
|
style.display !== 'none' &&
|
||||||
|
style.visibility !== 'hidden';
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
// Retry after a short delay
|
||||||
|
setTimeout(initWaveSurfer, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Initializing WaveSurfer...', {
|
||||||
|
container,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get computed CSS variable values
|
||||||
|
const root = document.documentElement;
|
||||||
|
const getCSSVar = (varName: string) => {
|
||||||
|
const value = getComputedStyle(root).getPropertyValue(varName).trim();
|
||||||
|
return value ? `hsl(${value})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const waveColor = getCSSVar('--muted');
|
||||||
|
const progressColor = getCSSVar('--accent');
|
||||||
|
const cursorColor = getCSSVar('--accent');
|
||||||
|
|
||||||
|
const wavesurfer = WaveSurfer.create({
|
||||||
|
container: container,
|
||||||
|
waveColor: waveColor,
|
||||||
|
progressColor: progressColor,
|
||||||
|
cursorColor: cursorColor,
|
||||||
|
barWidth: 2,
|
||||||
|
barRadius: 2,
|
||||||
|
height: 80,
|
||||||
|
normalize: true,
|
||||||
|
backend: 'WebAudio',
|
||||||
|
interact: true, // Enable interaction (click to seek)
|
||||||
|
mediaControls: false, // Don't show native controls
|
||||||
|
});
|
||||||
|
|
||||||
|
wavesurferRef.current = wavesurfer;
|
||||||
|
console.log('WaveSurfer created successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create WaveSurfer:', error);
|
||||||
|
setError(
|
||||||
|
`Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wavesurfer = wavesurferRef.current;
|
||||||
|
if (!wavesurfer) return;
|
||||||
|
|
||||||
|
// Update store when time changes
|
||||||
|
wavesurfer.on('timeupdate', (time) => {
|
||||||
|
setCurrentTime(time);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update store when duration is loaded
|
||||||
|
wavesurfer.on('ready', () => {
|
||||||
|
const dur = wavesurfer.getDuration();
|
||||||
|
setDuration(dur);
|
||||||
|
loadingRef.current = false;
|
||||||
|
setIsLoading(false);
|
||||||
|
setError(null);
|
||||||
|
console.log('Audio ready, duration:', dur);
|
||||||
|
console.log('Waveform should be visible now');
|
||||||
|
|
||||||
|
// Ensure volume is set
|
||||||
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
|
wavesurfer.setVolume(currentVolume);
|
||||||
|
|
||||||
|
// Get the underlying audio element and ensure it's not muted
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.volume = currentVolume;
|
||||||
|
mediaElement.muted = false;
|
||||||
|
console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-play when ready
|
||||||
|
// Use a small delay to ensure audio element is fully ready
|
||||||
|
setTimeout(() => {
|
||||||
|
wavesurfer.play().catch((error) => {
|
||||||
|
console.error('Failed to autoplay:', error);
|
||||||
|
// Don't show error for autoplay failures (browser restrictions)
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle play/pause
|
||||||
|
wavesurfer.on('play', () => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
// Ensure audio element is not muted when playing
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.muted = false;
|
||||||
|
const currentVolume = usePlayerStore.getState().volume;
|
||||||
|
mediaElement.volume = currentVolume;
|
||||||
|
console.log('Playing - volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
wavesurfer.on('pause', () => setIsPlaying(false));
|
||||||
|
wavesurfer.on('finish', () => {
|
||||||
|
// Check loop state from store
|
||||||
|
const loop = usePlayerStore.getState().isLooping;
|
||||||
|
if (loop) {
|
||||||
|
wavesurfer.seekTo(0);
|
||||||
|
wavesurfer.play();
|
||||||
|
} else {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
wavesurfer.on('error', (error) => {
|
||||||
|
console.error('WaveSurfer error:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle loading
|
||||||
|
wavesurfer.on('loading', (percent) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
if (percent === 100) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load audio immediately if audioUrl is already set
|
||||||
|
if (audioUrl) {
|
||||||
|
console.log('WaveSurfer ready, loading audio:', audioUrl);
|
||||||
|
loadingRef.current = true;
|
||||||
|
setIsLoading(true);
|
||||||
|
// Stop any current playback before loading new audio
|
||||||
|
if (wavesurfer.isPlaying()) {
|
||||||
|
wavesurfer.pause();
|
||||||
|
}
|
||||||
|
wavesurfer
|
||||||
|
.load(audioUrl)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Audio loaded into WaveSurfer');
|
||||||
|
loadingRef.current = false;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load audio into WaveSurfer:', error);
|
||||||
|
loadingRef.current = false;
|
||||||
|
setIsLoading(false);
|
||||||
|
setError(
|
||||||
|
`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use double requestAnimationFrame to ensure DOM is fully rendered
|
||||||
|
let rafId1: number;
|
||||||
|
let rafId2: number;
|
||||||
|
let timeoutId: number | null = null;
|
||||||
|
|
||||||
|
rafId1 = requestAnimationFrame(() => {
|
||||||
|
rafId2 = requestAnimationFrame(() => {
|
||||||
|
// Add a small delay to ensure container is fully laid out
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
initWaveSurfer();
|
||||||
|
}, 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log('Cleaning up WaveSurfer initialization effect');
|
||||||
|
if (rafId1) cancelAnimationFrame(rafId1);
|
||||||
|
if (rafId2) cancelAnimationFrame(rafId2);
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
if (wavesurferRef.current) {
|
||||||
|
console.log('Destroying WaveSurfer instance');
|
||||||
|
try {
|
||||||
|
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.pause();
|
||||||
|
mediaElement.src = '';
|
||||||
|
}
|
||||||
|
wavesurferRef.current.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error destroying WaveSurfer:', error);
|
||||||
|
}
|
||||||
|
wavesurferRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [audioUrl, setIsPlaying, setCurrentTime, setDuration]);
|
||||||
|
|
||||||
|
// Load audio when URL changes (only if WaveSurfer is already initialized)
|
||||||
|
useEffect(() => {
|
||||||
|
const wavesurfer = wavesurferRef.current;
|
||||||
|
|
||||||
|
if (!audioUrl || !wavesurfer) {
|
||||||
|
// Reset state when no audio or WaveSurfer not ready
|
||||||
|
if (!audioUrl && wavesurfer) {
|
||||||
|
wavesurfer.pause();
|
||||||
|
wavesurfer.seekTo(0);
|
||||||
|
loadingRef.current = false;
|
||||||
|
setIsLoading(false);
|
||||||
|
setDuration(0);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Force stop any current playback and cancel any pending loads
|
||||||
|
// This must happen BEFORE any early returns
|
||||||
|
console.log('Audio URL changed to:', audioUrl);
|
||||||
|
|
||||||
|
// COMPLETELY stop and destroy the current audio
|
||||||
|
try {
|
||||||
|
// First pause if playing
|
||||||
|
if (wavesurfer.isPlaying()) {
|
||||||
|
console.log('Pausing current playback');
|
||||||
|
wavesurfer.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the media element explicitly
|
||||||
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
console.log('Stopping media element');
|
||||||
|
mediaElement.pause();
|
||||||
|
mediaElement.currentTime = 0;
|
||||||
|
mediaElement.src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use empty() to completely destroy the waveform and media element
|
||||||
|
console.log('Calling wavesurfer.empty() to destroy audio');
|
||||||
|
wavesurfer.empty();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error stopping previous audio:', error);
|
||||||
|
// Continue anyway to load new audio
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset loading state to allow new load (cancel any pending loads)
|
||||||
|
loadingRef.current = false;
|
||||||
|
|
||||||
|
// Now start the new load
|
||||||
|
loadingRef.current = true;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setDuration(0);
|
||||||
|
|
||||||
|
// Load new audio
|
||||||
|
console.log('Starting new audio load for:', audioUrl);
|
||||||
|
wavesurfer
|
||||||
|
.load(audioUrl)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Audio load promise resolved');
|
||||||
|
// Don't set loading to false here - wait for 'ready' event
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load audio:', error);
|
||||||
|
console.error('Audio URL:', audioUrl);
|
||||||
|
loadingRef.current = false;
|
||||||
|
setIsLoading(false);
|
||||||
|
setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
});
|
||||||
|
}, [audioUrl, setCurrentTime, setDuration]);
|
||||||
|
|
||||||
|
// Sync play/pause state (only when user clicks play/pause button, not auto-sync)
|
||||||
|
// This effect is kept for external state changes but should be minimal
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wavesurferRef.current || duration === 0) return;
|
||||||
|
|
||||||
|
if (isPlaying && wavesurferRef.current.isPlaying() === false) {
|
||||||
|
// Only auto-play if audio is ready
|
||||||
|
wavesurferRef.current.play().catch((error) => {
|
||||||
|
console.error('Failed to play:', error);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
});
|
||||||
|
} else if (!isPlaying && wavesurferRef.current.isPlaying()) {
|
||||||
|
wavesurferRef.current.pause();
|
||||||
|
}
|
||||||
|
}, [isPlaying, setIsPlaying, duration]);
|
||||||
|
|
||||||
|
// Sync volume
|
||||||
|
useEffect(() => {
|
||||||
|
if (wavesurferRef.current) {
|
||||||
|
wavesurferRef.current.setVolume(volume);
|
||||||
|
// Also ensure the underlying audio element volume is set
|
||||||
|
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.volume = volume;
|
||||||
|
mediaElement.muted = volume === 0;
|
||||||
|
console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
// Handle loop - WaveSurfer handles this via the 'finish' event
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (!wavesurferRef.current) {
|
||||||
|
console.error('WaveSurfer not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if audio is loaded
|
||||||
|
if (duration === 0 && !isLoading) {
|
||||||
|
console.error('Audio not loaded yet');
|
||||||
|
setError('Audio not loaded. Please wait...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wavesurferRef.current.isPlaying()) {
|
||||||
|
wavesurferRef.current.pause();
|
||||||
|
} else {
|
||||||
|
wavesurferRef.current.play().catch((error) => {
|
||||||
|
console.error('Failed to play:', error);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (value: number[]) => {
|
||||||
|
if (!wavesurferRef.current || duration === 0) return;
|
||||||
|
const progress = value[0] / 100;
|
||||||
|
wavesurferRef.current.seekTo(progress);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = (value: number[]) => {
|
||||||
|
setVolume(value[0] / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't render if no audio
|
||||||
|
if (!audioUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-50">
|
||||||
|
<div className="container mx-auto px-4 py-3 max-w-7xl">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Play/Pause Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
disabled={isLoading || duration === 0}
|
||||||
|
className="shrink-0"
|
||||||
|
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Waveform */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||||
|
<div ref={waveformRef} className="w-full min-h-[80px]" />
|
||||||
|
{duration > 0 && (
|
||||||
|
<Slider
|
||||||
|
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
|
||||||
|
onValueChange={handleSeek}
|
||||||
|
max={100}
|
||||||
|
step={0.1}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-2">Loading audio...</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="text-xs text-destructive text-center py-2">{error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Display */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground shrink-0 min-w-[100px]">
|
||||||
|
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="font-mono">{formatAudioDuration(duration)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
{title && (
|
||||||
|
<div className="text-sm font-medium truncate max-w-[200px] shrink-0">{title}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loop Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleLoop}
|
||||||
|
className={isLooping ? 'text-primary' : ''}
|
||||||
|
title="Toggle loop"
|
||||||
|
>
|
||||||
|
<Repeat className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Volume Control */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0 w-[120px]">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setVolume(volume > 0 ? 0 : 1)}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Slider
|
||||||
|
value={[volume * 100]}
|
||||||
|
onValueChange={handleVolumeChange}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2, Mic } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
@@ -24,42 +25,54 @@ import {
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useGeneration } from '@/lib/hooks/useGeneration';
|
import { useGeneration } from '@/lib/hooks/useGeneration';
|
||||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
|
||||||
const generationSchema = z.object({
|
const generationSchema = z.object({
|
||||||
profileId: z.string().min(1, 'Please select a voice profile'),
|
|
||||||
text: z.string().min(1, 'Text is required').max(5000),
|
text: z.string().min(1, 'Text is required').max(5000),
|
||||||
language: z.enum(['en', 'zh']),
|
language: z.enum(['en', 'zh']),
|
||||||
seed: z.number().int().optional(),
|
seed: z.number().int().optional(),
|
||||||
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
||||||
|
instruct: z.string().max(500).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GenerationFormValues = z.infer<typeof generationSchema>;
|
type GenerationFormValues = z.infer<typeof generationSchema>;
|
||||||
|
|
||||||
export function GenerationForm() {
|
export function GenerationForm() {
|
||||||
const { data: profiles } = useProfiles();
|
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||||
|
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||||
const generation = useGeneration();
|
const generation = useGeneration();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const form = useForm<GenerationFormValues>({
|
const form = useForm<GenerationFormValues>({
|
||||||
resolver: zodResolver(generationSchema),
|
resolver: zodResolver(generationSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
profileId: '',
|
|
||||||
text: '',
|
text: '',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
seed: undefined,
|
seed: undefined,
|
||||||
modelSize: '1.7B',
|
modelSize: '1.7B',
|
||||||
|
instruct: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(data: GenerationFormValues) {
|
async function onSubmit(data: GenerationFormValues) {
|
||||||
|
if (!selectedProfileId) {
|
||||||
|
toast({
|
||||||
|
title: 'No profile selected',
|
||||||
|
description: 'Please select a voice profile from the cards above.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await generation.mutateAsync({
|
const result = await generation.mutateAsync({
|
||||||
profile_id: data.profileId,
|
profile_id: selectedProfileId,
|
||||||
text: data.text,
|
text: data.text,
|
||||||
language: data.language,
|
language: data.language,
|
||||||
seed: data.seed,
|
seed: data.seed,
|
||||||
model_size: data.modelSize,
|
model_size: data.modelSize,
|
||||||
|
instruct: data.instruct || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -85,30 +98,20 @@ export function GenerationForm() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<FormField
|
<div>
|
||||||
control={form.control}
|
|
||||||
name="profileId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Voice Profile</FormLabel>
|
<FormLabel>Voice Profile</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
{selectedProfile ? (
|
||||||
<FormControl>
|
<div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
|
||||||
<SelectTrigger>
|
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||||
<SelectValue placeholder="Select a voice" />
|
<span className="font-medium">{selectedProfile.name}</span>
|
||||||
</SelectTrigger>
|
<Badge variant="outline">{selectedProfile.language}</Badge>
|
||||||
</FormControl>
|
</div>
|
||||||
<SelectContent>
|
) : (
|
||||||
{profiles?.map((profile) => (
|
<div className="mt-2 p-3 border border-dashed rounded-md text-sm text-muted-foreground">
|
||||||
<SelectItem key={profile.id} value={profile.id}>
|
Click on a profile card above to select a voice profile
|
||||||
{profile.name}
|
</div>
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
)}
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -119,7 +122,7 @@ export function GenerationForm() {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Enter the text you want to generate..."
|
placeholder="Enter the text you want to generate..."
|
||||||
className="min-h-[200px]"
|
className="min-h-[150px]"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -129,6 +132,28 @@ export function GenerationForm() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="instruct"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Delivery Instructions (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
|
||||||
|
className="min-h-[80px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Natural language instructions to control speech delivery (tone, emotion, pace).
|
||||||
|
Max 500 characters
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -198,7 +223,11 @@ export function GenerationForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={generation.isPending}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={generation.isPending || !selectedProfileId}
|
||||||
|
>
|
||||||
{generation.isPending ? (
|
{generation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Download, Play, Trash2 } from 'lucide-react';
|
import { Download, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -10,15 +16,15 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory';
|
import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { formatDate, formatDuration } from '@/lib/utils/format';
|
import { formatDate, formatDuration } from '@/lib/utils/format';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
|
|
||||||
export function HistoryTable() {
|
export function HistoryTable() {
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { data: historyData, isLoading } = useHistory({
|
const { data: historyData, isLoading } = useHistory({
|
||||||
limit,
|
limit,
|
||||||
@@ -26,17 +32,16 @@ export function HistoryTable() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteGeneration = useDeleteGeneration();
|
const deleteGeneration = useDeleteGeneration();
|
||||||
|
const setAudio = usePlayerStore((state) => state.setAudio);
|
||||||
|
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||||
|
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
||||||
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
|
const isPlayerVisible = !!audioUrl;
|
||||||
|
|
||||||
const handlePlay = (audioId: string) => {
|
const handlePlay = (audioId: string, text: string) => {
|
||||||
const audioUrl = apiClient.getAudioUrl(audioId);
|
const audioUrl = apiClient.getAudioUrl(audioId);
|
||||||
const audio = new Audio(audioUrl);
|
// If clicking the same audio that's playing, it will be handled by the player
|
||||||
audio.play().catch((_error) => {
|
setAudio(audioUrl, audioId, text.substring(0, 50));
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: 'Failed to play audio',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = (audioId: string, text: string) => {
|
const handleDownload = (audioId: string, text: string) => {
|
||||||
@@ -63,71 +68,92 @@ export function HistoryTable() {
|
|||||||
const hasMore = history.length === limit && (page + 1) * limit < total;
|
const hasMore = history.length === limit && (page + 1) * limit < total;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<h2 className="text-2xl font-bold">Generation History</h2>
|
|
||||||
|
|
||||||
{history.length === 0 ? (
|
{history.length === 0 ? (
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
<div className="text-center py-12 text-muted-foreground flex-1 flex items-center justify-center">
|
||||||
No generation history yet. Generate your first audio to see it here.
|
No generation history yet. Generate your first audio to see it here.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Table>
|
<div
|
||||||
<TableHeader>
|
className={cn(
|
||||||
|
'flex-1 min-h-0 overflow-y-auto border rounded-md overflow-x-hidden',
|
||||||
|
isPlayerVisible && 'max-h-[calc(100vh-220px)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Table className="w-full table-fixed">
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Text</TableHead>
|
<TableHead className="w-[38%]">Input</TableHead>
|
||||||
<TableHead>Profile</TableHead>
|
<TableHead className="w-[13%]">Voice</TableHead>
|
||||||
<TableHead>Language</TableHead>
|
<TableHead className="w-[9%]">Lang</TableHead>
|
||||||
<TableHead>Duration</TableHead>
|
<TableHead className="w-[9%]">Length</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead className="w-[13%]">Date</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="w-[8%] text-right"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{history.map((gen) => (
|
{history.map((gen) => {
|
||||||
<TableRow key={gen.id}>
|
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
||||||
<TableCell className="max-w-[300px] truncate">{gen.text}</TableCell>
|
return (
|
||||||
<TableCell>{gen.profile_name}</TableCell>
|
<TableRow
|
||||||
|
key={gen.id}
|
||||||
|
className={cn(isCurrentlyPlaying && 'bg-muted/50', 'cursor-pointer')}
|
||||||
|
onClick={() => handlePlay(gen.id, gen.text)}
|
||||||
|
>
|
||||||
|
<TableCell className="truncate">{gen.text}</TableCell>
|
||||||
|
<TableCell className="truncate">{gen.profile_name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">{gen.language}</Badge>
|
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||||
|
{gen.language}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{formatDuration(gen.duration)}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground/60">
|
||||||
|
{formatDate(gen.created_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{formatDuration(gen.duration)}</TableCell>
|
|
||||||
<TableCell>{formatDate(gen.created_at)}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handlePlay(gen.id)}
|
className="h-7 w-7 rounded-full"
|
||||||
aria-label="Play audio"
|
aria-label="Actions"
|
||||||
>
|
>
|
||||||
<Play className="h-4 w-4" />
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
</DropdownMenuTrigger>
|
||||||
variant="ghost"
|
<DropdownMenuContent align="end">
|
||||||
size="icon"
|
<DropdownMenuItem onClick={() => handlePlay(gen.id, gen.text)}>
|
||||||
onClick={() => handleDownload(gen.id, gen.text)}
|
<Play className="mr-2 h-4 w-4" />
|
||||||
aria-label="Download audio"
|
Play
|
||||||
>
|
</DropdownMenuItem>
|
||||||
<Download className="h-4 w-4" />
|
<DropdownMenuItem onClick={() => handleDownload(gen.id, gen.text)}>
|
||||||
</Button>
|
<Download className="mr-2 h-4 w-4" />
|
||||||
<Button
|
Download
|
||||||
variant="ghost"
|
</DropdownMenuItem>
|
||||||
size="icon"
|
<DropdownMenuItem
|
||||||
onClick={() => deleteGeneration.mutate(gen.id)}
|
onClick={() => deleteGeneration.mutate(gen.id)}
|
||||||
disabled={deleteGeneration.isPending}
|
disabled={deleteGeneration.isPending}
|
||||||
aria-label="Delete generation"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center mt-4 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
@@ -135,7 +161,7 @@ export function HistoryTable() {
|
|||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
<div className="text-sm text-muted-foreground flex items-center">
|
<div className="text-sm text-muted-foreground">
|
||||||
Page {page + 1} • {total} total
|
Page {page + 1} • {total} total
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>
|
<Button variant="outline" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
@@ -25,6 +27,8 @@ type ConnectionFormValues = z.infer<typeof connectionSchema>;
|
|||||||
export function ConnectionForm() {
|
export function ConnectionForm() {
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
||||||
|
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
||||||
|
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const form = useForm<ConnectionFormValues>({
|
const form = useForm<ConnectionFormValues>({
|
||||||
@@ -34,8 +38,16 @@ export function ConnectionForm() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync form with store when serverUrl changes externally
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({ serverUrl });
|
||||||
|
}, [serverUrl, form]);
|
||||||
|
|
||||||
|
const { isDirty } = form.formState;
|
||||||
|
|
||||||
function onSubmit(data: ConnectionFormValues) {
|
function onSubmit(data: ConnectionFormValues) {
|
||||||
setServerUrl(data.serverUrl);
|
setServerUrl(data.serverUrl);
|
||||||
|
form.reset(data); // Reset form state after successful submission
|
||||||
toast({
|
toast({
|
||||||
title: 'Server URL updated',
|
title: 'Server URL updated',
|
||||||
description: `Connected to ${data.serverUrl}`,
|
description: `Connected to ${data.serverUrl}`,
|
||||||
@@ -65,9 +77,39 @@ export function ConnectionForm() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit">Update Connection</Button>
|
{isDirty && <Button type="submit">Update Connection</Button>}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id="keepServerRunning"
|
||||||
|
checked={keepServerRunningOnClose}
|
||||||
|
onCheckedChange={(checked: boolean) => {
|
||||||
|
setKeepServerRunningOnClose(checked);
|
||||||
|
toast({
|
||||||
|
title: 'Setting updated',
|
||||||
|
description: checked
|
||||||
|
? 'Server will continue running when app closes'
|
||||||
|
: 'Server will stop when app closes',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="keepServerRunning"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
Keep server running when app closes
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
When enabled, the server will continue running in the background after closing the
|
||||||
|
app. Disabled by default.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ export function ModelManagement() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* TTS Models */}
|
{/* TTS Models */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">Voice Generation Models</h3>
|
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
|
||||||
|
Voice Generation Models
|
||||||
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{modelStatus.models
|
{modelStatus.models
|
||||||
.filter((m) => m.model_name.startsWith('qwen-tts'))
|
.filter((m) => m.model_name.startsWith('qwen-tts'))
|
||||||
@@ -91,7 +93,9 @@ export function ModelManagement() {
|
|||||||
|
|
||||||
{/* Whisper Models */}
|
{/* Whisper Models */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">Transcription Models</h3>
|
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
|
||||||
|
Transcription Models
|
||||||
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{modelStatus.models
|
{modelStatus.models
|
||||||
.filter((m) => m.model_name.startsWith('whisper'))
|
.filter((m) => m.model_name.startsWith('whisper'))
|
||||||
@@ -109,7 +113,9 @@ export function ModelManagement() {
|
|||||||
|
|
||||||
{/* Progress indicators */}
|
{/* Progress indicators */}
|
||||||
<div className="pt-4 border-t">
|
<div className="pt-4 border-t">
|
||||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">Download Progress</h3>
|
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
|
||||||
|
Download Progress
|
||||||
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{modelStatus.models.map((model) => (
|
{modelStatus.models.map((model) => (
|
||||||
<ModelProgress
|
<ModelProgress
|
||||||
@@ -147,10 +153,14 @@ function ModelItem({ model, onDownload, isDownloading, formatSize }: ModelItemPr
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-sm">{model.display_name}</span>
|
<span className="font-medium text-sm">{model.display_name}</span>
|
||||||
{model.loaded && (
|
{model.loaded && (
|
||||||
<Badge variant="default" className="text-xs">Loaded</Badge>
|
<Badge variant="default" className="text-xs">
|
||||||
|
Loaded
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{model.downloaded && !model.loaded && (
|
{model.downloaded && !model.loaded && (
|
||||||
<Badge variant="secondary" className="text-xs">Downloaded</Badge>
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Downloaded
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{model.downloaded && model.size_mb && (
|
{model.downloaded && model.size_mb && (
|
||||||
@@ -166,12 +176,7 @@ function ModelItem({ model, onDownload, isDownloading, formatSize }: ModelItemPr
|
|||||||
<span>Ready</span>
|
<span>Ready</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button size="sm" onClick={onDownload} disabled={isDownloading} variant="outline">
|
||||||
size="sm"
|
|
||||||
onClick={onDownload}
|
|
||||||
disabled={isDownloading}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{isDownloading ? (
|
{isDownloading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
}, [serverUrl, modelName, isSubscribed]);
|
}, [serverUrl, modelName, isSubscribed]);
|
||||||
|
|
||||||
// Don't render if no progress or if complete/error and some time has passed
|
// Don't render if no progress or if complete/error and some time has passed
|
||||||
if (!progress || (progress.status === 'complete' && Date.now() - new Date(progress.timestamp).getTime() > 5000)) {
|
if (
|
||||||
|
!progress ||
|
||||||
|
(progress.status === 'complete' && Date.now() - new Date(progress.timestamp).getTime() > 5000)
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,9 +114,7 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{progress.total > 0 && (
|
{progress.total > 0 && <Progress value={progress.progress} className="h-2" />}
|
||||||
<Progress value={progress.progress} className="h-2" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export function ServerStatus() {
|
|||||||
<span className="text-sm">Connected</span>
|
<span className="text-sm">Connected</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge variant={health.model_loaded || health.model_downloaded ? 'default' : 'secondary'}>
|
<Badge
|
||||||
|
variant={health.model_loaded || health.model_downloaded ? 'default' : 'secondary'}
|
||||||
|
>
|
||||||
{health.model_loaded || health.model_downloaded ? 'Model Ready' : 'No Model'}
|
{health.model_loaded || health.model_downloaded ? 'Model Ready' : 'No Model'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant={health.gpu_available ? 'default' : 'secondary'}>
|
<Badge variant={health.gpu_available ? 'default' : 'secondary'}>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { RefreshCw, Download, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||||
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
|
|
||||||
|
export function UpdateStatus() {
|
||||||
|
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(false);
|
||||||
|
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getVersion()
|
||||||
|
.then(setCurrentVersion)
|
||||||
|
.catch(() => setCurrentVersion('0.1.0'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>App Updates</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium">Current Version</div>
|
||||||
|
<div className="text-sm text-muted-foreground">v{currentVersion}</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={checkForUpdates}
|
||||||
|
disabled={status.checking || status.downloading || status.installing}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
|
||||||
|
Check for Updates
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.checking && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
Checking for updates...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status.error && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
{status.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status.available && !status.downloading && !status.installing && (
|
||||||
|
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold">Update Available</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Version {status.version}</div>
|
||||||
|
</div>
|
||||||
|
<Badge>New</Badge>
|
||||||
|
</div>
|
||||||
|
<Button onClick={downloadAndInstall} className="w-full" size="sm">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Install Update
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status.downloading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Downloading update...
|
||||||
|
</div>
|
||||||
|
<Progress />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status.installing && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
Installing update...
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">App will restart automatically</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!status.available && !status.checking && !status.error && status.checking === false && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
You're up to date
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { History, Mic, Settings, Sparkles } from 'lucide-react';
|
import { Home, Settings } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeTab: string;
|
activeTab: string;
|
||||||
@@ -7,15 +8,22 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'profiles', icon: Mic, label: 'Profiles' },
|
{ id: 'main', icon: Home, label: 'Main' },
|
||||||
{ id: 'generate', icon: Sparkles, label: 'Generate' },
|
|
||||||
{ id: 'history', icon: History, label: 'History' },
|
|
||||||
{ id: 'settings', icon: Settings, label: 'Settings' },
|
{ id: 'settings', icon: Settings, label: 'Settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
|
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6">
|
<div className="fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<img
|
||||||
|
src={voiceboxLogo}
|
||||||
|
alt="Voicebox"
|
||||||
|
className="w-12 h-12 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
{/* Navigation Buttons */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
@@ -28,11 +36,9 @@ export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onTabChange(tab.id)}
|
onClick={() => onTabChange(tab.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200",
|
'w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200',
|
||||||
"hover:bg-accent hover:text-accent-foreground",
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
isActive
|
isActive ? 'bg-accent text-accent-foreground shadow-lg' : 'text-muted-foreground',
|
||||||
? "bg-primary text-primary-foreground shadow-lg"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
)}
|
)}
|
||||||
title={tab.label}
|
title={tab.label}
|
||||||
aria-label={tab.label}
|
aria-label={tab.label}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useAutoUpdater } from '../hooks/useAutoUpdater';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Card } from './ui/card';
|
||||||
|
import { Progress } from './ui/progress';
|
||||||
|
|
||||||
|
export function UpdateNotification() {
|
||||||
|
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(true);
|
||||||
|
|
||||||
|
if (status.error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status.available && !status.checking) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.checking) {
|
||||||
|
return (
|
||||||
|
<Card className="p-4 mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
|
||||||
|
<span className="text-sm">Checking for updates...</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.available) {
|
||||||
|
return (
|
||||||
|
<Card className="p-4 mb-4 border-primary">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">Update Available</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Version {status.version} is ready to install
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.downloading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm">Downloading update...</p>
|
||||||
|
<Progress />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status.installing && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm">Installing update...</p>
|
||||||
|
<p className="text-xs text-muted-foreground">App will restart automatically</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!status.downloading && !status.installing && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={downloadAndInstall} size="sm">
|
||||||
|
Install Now
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => window.location.reload()} variant="outline" size="sm">
|
||||||
|
Later
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
import { Edit, Mic, Trash2 } from 'lucide-react';
|
import { Edit, Eye, Mic, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { CircleButton } from '@/components/ui/circle-button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||||
import { useDeleteProfile } from '@/lib/hooks/useProfiles';
|
import { useDeleteProfile } from '@/lib/hooks/useProfiles';
|
||||||
import { formatDate } from '@/lib/utils/format';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { ProfileDetail } from './ProfileDetail';
|
import { ProfileDetail } from './ProfileDetail';
|
||||||
|
|
||||||
@@ -15,63 +24,111 @@ interface ProfileCardProps {
|
|||||||
|
|
||||||
export function ProfileCard({ profile }: ProfileCardProps) {
|
export function ProfileCard({ profile }: ProfileCardProps) {
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const deleteProfile = useDeleteProfile();
|
const deleteProfile = useDeleteProfile();
|
||||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||||
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||||
|
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||||
|
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||||
|
|
||||||
|
const isSelected = selectedProfileId === profile.id;
|
||||||
|
|
||||||
|
const handleSelect = () => {
|
||||||
|
setSelectedProfileId(isSelected ? null : profile.id);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
setEditingProfileId(profile.id);
|
setEditingProfileId(profile.id);
|
||||||
setProfileDialogOpen(true);
|
setProfileDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||||
if (
|
e.stopPropagation();
|
||||||
confirm(`Are you sure you want to delete "${profile.name}"? This action cannot be undone.`)
|
setDeleteDialogOpen(true);
|
||||||
) {
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
deleteProfile.mutate(profile.id);
|
deleteProfile.mutate(profile.id);
|
||||||
}
|
setDeleteDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
className="cursor-pointer hover:shadow-lg transition-shadow"
|
className={cn(
|
||||||
onClick={() => setDetailOpen(true)}
|
'cursor-pointer hover:shadow-md transition-all flex flex-col',
|
||||||
|
isSelected && 'ring-2 ring-primary shadow-md',
|
||||||
|
)}
|
||||||
|
onClick={handleSelect}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader className="p-3 pb-2">
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center gap-1.5 text-base font-medium">
|
||||||
<span className="flex items-center gap-2">
|
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0">
|
||||||
<Mic className="h-5 w-5" />
|
<Mic className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
{profile.name}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Button variant="ghost" size="icon" onClick={handleEdit} aria-label="Edit profile">
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleteProfile.isPending}
|
|
||||||
aria-label="Delete profile"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="break-words">{profile.name}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-3 pt-0 flex flex-col flex-1">
|
||||||
{profile.description && (
|
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
|
||||||
<p className="text-sm text-muted-foreground mb-2">{profile.description}</p>
|
{profile.description || 'No description'}
|
||||||
)}
|
</p>
|
||||||
<div className="flex gap-2 mb-2">
|
<div className="mb-2">
|
||||||
<Badge variant="outline">{profile.language}</Badge>
|
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
|
||||||
|
{profile.language}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
||||||
|
<CircleButton
|
||||||
|
icon={Eye}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDetailOpen(true);
|
||||||
|
}}
|
||||||
|
aria-label="View details"
|
||||||
|
/>
|
||||||
|
<CircleButton
|
||||||
|
icon={Edit}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEdit();
|
||||||
|
}}
|
||||||
|
aria-label="Edit profile"
|
||||||
|
/>
|
||||||
|
<CircleButton
|
||||||
|
icon={Trash2}
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
disabled={deleteProfile.isPending}
|
||||||
|
aria-label="Delete profile"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">Created {formatDate(profile.created_at)}</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ProfileDetail profileId={profile.id} open={detailOpen} onOpenChange={setDetailOpen} />
|
<ProfileDetail profileId={profile.id} open={detailOpen} onOpenChange={setDetailOpen} />
|
||||||
|
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Profile</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete "{profile.name}"? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteProfile.isPending}
|
||||||
|
>
|
||||||
|
{deleteProfile.isPending ? 'Deleting...' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
@@ -27,15 +28,64 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useCreateProfile, useProfile, useUpdateProfile } from '@/lib/hooks/useProfiles';
|
import {
|
||||||
|
useCreateProfile,
|
||||||
|
useProfile,
|
||||||
|
useUpdateProfile,
|
||||||
|
useAddSample,
|
||||||
|
} from '@/lib/hooks/useProfiles';
|
||||||
|
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||||
|
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { Mic, Square, Upload } from 'lucide-react';
|
||||||
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
|
|
||||||
const profileSchema = z.object({
|
// Helper function to get audio duration from File
|
||||||
|
async function getAudioDuration(file: File): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const audio = new Audio();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(audio.duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.addEventListener('error', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('Failed to load audio file'));
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_AUDIO_DURATION_SECONDS = 30;
|
||||||
|
|
||||||
|
const profileSchema = z
|
||||||
|
.object({
|
||||||
name: z.string().min(1, 'Name is required').max(100),
|
name: z.string().min(1, 'Name is required').max(100),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
language: z.enum(['en', 'zh']),
|
language: z.enum(['en', 'zh']),
|
||||||
});
|
// Sample fields - only required when creating (not editing)
|
||||||
|
sampleFile: z.instanceof(File).optional(),
|
||||||
|
referenceText: z.string().max(1000).optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
// If sample file is provided, reference text is required
|
||||||
|
if (data.sampleFile && (!data.referenceText || data.referenceText.trim().length === 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Reference text is required when adding a sample',
|
||||||
|
path: ['referenceText'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
type ProfileFormValues = z.infer<typeof profileSchema>;
|
type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||||
|
|
||||||
@@ -47,7 +97,13 @@ export function ProfileForm() {
|
|||||||
const { data: editingProfile } = useProfile(editingProfileId || '');
|
const { data: editingProfile } = useProfile(editingProfileId || '');
|
||||||
const createProfile = useCreateProfile();
|
const createProfile = useCreateProfile();
|
||||||
const updateProfile = useUpdateProfile();
|
const updateProfile = useUpdateProfile();
|
||||||
|
const addSample = useAddSample();
|
||||||
|
const transcribe = useTranscription();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [sampleMode, setSampleMode] = useState<'upload' | 'record'>('upload');
|
||||||
|
const [audioDuration, setAudioDuration] = useState<number | null>(null);
|
||||||
|
const [isValidatingAudio, setIsValidatingAudio] = useState(false);
|
||||||
|
const isCreating = !editingProfileId;
|
||||||
|
|
||||||
const form = useForm<ProfileFormValues>({
|
const form = useForm<ProfileFormValues>({
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
@@ -58,39 +114,213 @@ export function ProfileForm() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedFile = form.watch('sampleFile');
|
||||||
|
|
||||||
|
// Validate audio duration when file is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFile && selectedFile instanceof File) {
|
||||||
|
setIsValidatingAudio(true);
|
||||||
|
getAudioDuration(selectedFile)
|
||||||
|
.then((duration) => {
|
||||||
|
setAudioDuration(duration);
|
||||||
|
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||||
|
form.setError('sampleFile', {
|
||||||
|
type: 'manual',
|
||||||
|
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.clearErrors('sampleFile');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to get audio duration:', error);
|
||||||
|
setAudioDuration(null);
|
||||||
|
form.setError('sampleFile', {
|
||||||
|
type: 'manual',
|
||||||
|
message: 'Failed to validate audio file. Please try a different file.',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsValidatingAudio(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAudioDuration(null);
|
||||||
|
form.clearErrors('sampleFile');
|
||||||
|
}
|
||||||
|
}, [selectedFile, form]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isRecording,
|
||||||
|
duration,
|
||||||
|
error: recordingError,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
cancelRecording,
|
||||||
|
} = useAudioRecording({
|
||||||
|
maxDurationSeconds: 30,
|
||||||
|
onRecordingComplete: (blob) => {
|
||||||
|
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
||||||
|
type: blob.type || 'audio/webm',
|
||||||
|
});
|
||||||
|
form.setValue('sampleFile', file, { shouldValidate: true });
|
||||||
|
toast({
|
||||||
|
title: 'Recording complete',
|
||||||
|
description: 'Audio has been recorded successfully.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show recording errors
|
||||||
|
useEffect(() => {
|
||||||
|
if (recordingError) {
|
||||||
|
toast({
|
||||||
|
title: 'Recording error',
|
||||||
|
description: recordingError,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [recordingError, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingProfile) {
|
if (editingProfile) {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: editingProfile.name,
|
name: editingProfile.name,
|
||||||
description: editingProfile.description || '',
|
description: editingProfile.description || '',
|
||||||
language: editingProfile.language as 'en' | 'zh',
|
language: editingProfile.language as 'en' | 'zh',
|
||||||
|
sampleFile: undefined,
|
||||||
|
referenceText: undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
|
sampleFile: undefined,
|
||||||
|
referenceText: undefined,
|
||||||
});
|
});
|
||||||
|
setSampleMode('upload');
|
||||||
}
|
}
|
||||||
}, [editingProfile, form]);
|
}, [editingProfile, form]);
|
||||||
|
|
||||||
|
async function handleTranscribe() {
|
||||||
|
const file = form.getValues('sampleFile');
|
||||||
|
if (!file) {
|
||||||
|
toast({
|
||||||
|
title: 'No file selected',
|
||||||
|
description: 'Please select an audio file first.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const language = form.getValues('language') as 'en' | 'zh' | undefined;
|
||||||
|
const result = await transcribe.mutateAsync({ file, language });
|
||||||
|
|
||||||
|
form.setValue('referenceText', result.text, { shouldValidate: true });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Transcription complete',
|
||||||
|
description: 'Audio has been transcribed successfully.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Transcription failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Failed to transcribe audio',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelRecording() {
|
||||||
|
cancelRecording();
|
||||||
|
form.resetField('sampleFile');
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit(data: ProfileFormValues) {
|
async function onSubmit(data: ProfileFormValues) {
|
||||||
try {
|
try {
|
||||||
if (editingProfileId) {
|
if (editingProfileId) {
|
||||||
|
// Editing: just update profile
|
||||||
await updateProfile.mutateAsync({
|
await updateProfile.mutateAsync({
|
||||||
profileId: editingProfileId,
|
profileId: editingProfileId,
|
||||||
data,
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
language: data.language,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title: 'Profile updated',
|
title: 'Profile updated',
|
||||||
description: `"${data.name}" has been updated successfully.`,
|
description: `"${data.name}" has been updated successfully.`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await createProfile.mutateAsync(data);
|
// Get file and reference text directly from form state to ensure we have the values
|
||||||
|
const sampleFile = form.getValues('sampleFile');
|
||||||
|
const referenceText = form.getValues('referenceText');
|
||||||
|
|
||||||
|
// Validate audio duration before creating profile
|
||||||
|
if (sampleFile) {
|
||||||
|
try {
|
||||||
|
const duration = await getAudioDuration(sampleFile);
|
||||||
|
if (duration > MAX_AUDIO_DURATION_SECONDS) {
|
||||||
|
form.setError('sampleFile', {
|
||||||
|
type: 'manual',
|
||||||
|
message: `Audio is too long (${formatAudioDuration(duration)}). Maximum duration is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: 'Invalid audio file',
|
||||||
|
description: `Audio duration is ${formatAudioDuration(duration)}, but maximum is ${formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return; // Prevent form submission
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
form.setError('sampleFile', {
|
||||||
|
type: 'manual',
|
||||||
|
message: 'Failed to validate audio file. Please try a different file.',
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: 'Validation error',
|
||||||
|
description: error instanceof Error ? error.message : 'Failed to validate audio file',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return; // Prevent form submission
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creating: create profile, then optionally add sample
|
||||||
|
const profile = await createProfile.mutateAsync({
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
language: data.language,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If sample file and reference text provided, add it
|
||||||
|
if (sampleFile && referenceText && referenceText.trim().length > 0) {
|
||||||
|
try {
|
||||||
|
await addSample.mutateAsync({
|
||||||
|
profileId: profile.id,
|
||||||
|
file: sampleFile,
|
||||||
|
referenceText: referenceText,
|
||||||
|
});
|
||||||
toast({
|
toast({
|
||||||
title: 'Profile created',
|
title: 'Profile created',
|
||||||
description: `"${data.name}" has been created successfully.`,
|
description: `"${data.name}" has been created with a sample.`,
|
||||||
});
|
});
|
||||||
|
} catch (sampleError) {
|
||||||
|
// Profile was created but sample failed - still show success for profile
|
||||||
|
toast({
|
||||||
|
title: 'Profile created',
|
||||||
|
description: `"${data.name}" has been created, but failed to add sample: ${sampleError instanceof Error ? sampleError.message : 'Unknown error'}`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Profile created',
|
||||||
|
description: `"${data.name}" has been created successfully. You can add samples later.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -110,23 +340,30 @@ export function ProfileForm() {
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
setEditingProfileId(null);
|
setEditingProfileId(null);
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setSampleMode('upload');
|
||||||
|
if (isRecording) {
|
||||||
|
cancelRecording();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingProfileId ? 'Edit Profile' : 'Create Voice Profile'}</DialogTitle>
|
<DialogTitle>{editingProfileId ? 'Edit Profile' : 'Create Voice Profile'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{editingProfileId
|
{editingProfileId
|
||||||
? 'Update your voice profile details.'
|
? 'Update your voice profile details.'
|
||||||
: 'Add a new voice profile with samples.'}
|
: 'Create a new voice profile. You can add a sample now or later.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<div className={`grid gap-6 ${isCreating ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||||
|
{/* Left column: Profile info */}
|
||||||
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -146,7 +383,7 @@ export function ProfileForm() {
|
|||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel>Description (Optional)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea placeholder="Describe this voice..." {...field} />
|
<Textarea placeholder="Describe this voice..." {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -176,13 +413,236 @@ export function ProfileForm() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end">
|
{/* Right column: Sample upload section - only show when creating */}
|
||||||
|
{isCreating && (
|
||||||
|
<div className="space-y-4 border-l pl-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2">Add Sample (Optional)</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Add an audio sample to get started immediately. You can add more samples
|
||||||
|
later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={sampleMode}
|
||||||
|
onValueChange={(v) => setSampleMode(v as 'upload' | 'record')}
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Upload
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
Record
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="upload" className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sampleFile"
|
||||||
|
render={({ field: { onChange, name, ref } }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Audio File</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
name={name}
|
||||||
|
ref={ref}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
onChange(file);
|
||||||
|
} else {
|
||||||
|
onChange(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{selectedFile && (
|
||||||
|
<>
|
||||||
|
{isValidatingAudio && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Validating audio...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!isValidatingAudio && audioDuration !== null && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">Duration:</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
audioDuration > MAX_AUDIO_DURATION_SECONDS
|
||||||
|
? 'text-destructive font-medium'
|
||||||
|
: 'text-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatAudioDuration(audioDuration)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
/ {formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)} max
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTranscribe}
|
||||||
|
disabled={transcribe.isPending || isValidatingAudio || (audioDuration !== null && audioDuration > MAX_AUDIO_DURATION_SECONDS)}
|
||||||
|
className="flex items-center gap-2 w-full"
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Supported formats: WAV, MP3, M4A. Maximum duration:{' '}
|
||||||
|
{formatAudioDuration(MAX_AUDIO_DURATION_SECONDS)}. Click "Transcribe"
|
||||||
|
to automatically extract text from the audio.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="record" className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sampleFile"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Record Audio</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!isRecording && !selectedFile && (
|
||||||
|
<div className="flex flex-col items-center gap-4 p-4 border-2 border-dashed rounded-lg">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={startRecording}
|
||||||
|
size="lg"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Mic className="h-5 w-5" />
|
||||||
|
Start Recording
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Click to start recording. Maximum duration: 30 seconds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRecording && (
|
||||||
|
<div className="flex flex-col items-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
||||||
|
<span className="text-lg font-mono font-semibold">
|
||||||
|
{formatAudioDuration(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={stopRecording}
|
||||||
|
variant="destructive"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
Stop Recording
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Recording in progress... ({formatAudioDuration(30 - duration)}{' '}
|
||||||
|
remaining)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedFile && !isRecording && (
|
||||||
|
<div className="flex flex-col items-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mic className="h-5 w-5 text-primary" />
|
||||||
|
<span className="font-medium">Recording complete</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
File: {selectedFile.name}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTranscribe}
|
||||||
|
disabled={transcribe.isPending}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancelRecording}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Record Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Record audio directly from your microphone. Maximum duration is 30
|
||||||
|
seconds.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="referenceText"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Reference Text</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Enter the exact text spoken in the audio..."
|
||||||
|
className="min-h-[100px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
This should match exactly what is spoken in the audio file. Required if
|
||||||
|
you add a sample.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end mt-6 pt-4 border-t">
|
||||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={createProfile.isPending || updateProfile.isPending}>
|
<Button
|
||||||
{createProfile.isPending || updateProfile.isPending
|
type="submit"
|
||||||
|
disabled={createProfile.isPending || updateProfile.isPending || addSample.isPending}
|
||||||
|
>
|
||||||
|
{createProfile.isPending || updateProfile.isPending || addSample.isPending
|
||||||
? 'Saving...'
|
? 'Saving...'
|
||||||
: editingProfileId
|
: editingProfileId
|
||||||
? 'Update Profile'
|
? 'Update Profile'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Mic, Plus } from 'lucide-react';
|
import { Mic, Sparkles } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
@@ -26,17 +26,20 @@ export function ProfileList() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allProfiles = profiles || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-4 shrink-0">
|
||||||
<h2 className="text-2xl font-bold">Voice Profiles</h2>
|
<h2 className="text-2xl font-bold">Voicebox</h2>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
New Profile
|
New Profile
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{profiles && profiles.length === 0 ? (
|
<div className="shrink-0">
|
||||||
|
{allProfiles.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
|
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
@@ -44,18 +47,19 @@ export function ProfileList() {
|
|||||||
No voice profiles yet. Create your first profile to get started.
|
No voice profiles yet. Create your first profile to get started.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
Create Profile
|
Create Profile
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1">
|
||||||
{profiles?.map((profile) => (
|
{allProfiles.map((profile) => (
|
||||||
<ProfileCard key={profile.id} profile={profile} />
|
<ProfileCard key={profile.id} profile={profile} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ProfileForm />
|
<ProfileForm />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2, Play } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
|
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
import { usePlayerStore } from '@/stores/playerStore';
|
||||||
import { SampleUpload } from './SampleUpload';
|
import { SampleUpload } from './SampleUpload';
|
||||||
|
|
||||||
interface SampleListProps {
|
interface SampleListProps {
|
||||||
@@ -16,6 +17,9 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
const [uploadOpen, setUploadOpen] = useState(false);
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
const setAudio = usePlayerStore((state) => state.setAudio);
|
||||||
|
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||||
|
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
||||||
|
|
||||||
const handleDelete = (sampleId: string) => {
|
const handleDelete = (sampleId: string) => {
|
||||||
if (confirm('Are you sure you want to delete this sample?')) {
|
if (confirm('Are you sure you want to delete this sample?')) {
|
||||||
@@ -23,16 +27,9 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlay = (audioPath: string) => {
|
const handlePlay = (audioPath: string, referenceText: string, sampleId: string) => {
|
||||||
const audioUrl = `${serverUrl}${audioPath}`;
|
const audioUrl = `${serverUrl}${audioPath}`;
|
||||||
const audio = new Audio(audioUrl);
|
setAudio(audioUrl, sampleId, referenceText.substring(0, 50));
|
||||||
audio.play().catch((_error) => {
|
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: 'Failed to play audio',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -65,7 +62,13 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
<p className="text-xs text-muted-foreground mt-1">{sample.audio_path}</p>
|
<p className="text-xs text-muted-foreground mt-1">{sample.audio_path}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => handlePlay(sample.audio_path)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePlay(sample.audio_path, sample.reference_text, sample.id)}
|
||||||
|
className={currentAudioId === sample.id && isPlaying ? 'text-primary' : ''}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-1" />
|
||||||
Play
|
Play
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -20,8 +21,13 @@ import {
|
|||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useAddSample } from '@/lib/hooks/useProfiles';
|
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
||||||
|
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||||
|
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||||
|
import { Mic, Square, Upload } from 'lucide-react';
|
||||||
|
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||||
|
|
||||||
const sampleSchema = z.object({
|
const sampleSchema = z.object({
|
||||||
file: z.instanceof(File, { message: 'Please select an audio file' }),
|
file: z.instanceof(File, { message: 'Please select an audio file' }),
|
||||||
@@ -41,7 +47,10 @@ interface SampleUploadProps {
|
|||||||
|
|
||||||
export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) {
|
export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) {
|
||||||
const addSample = useAddSample();
|
const addSample = useAddSample();
|
||||||
|
const transcribe = useTranscription();
|
||||||
|
const { data: profile } = useProfile(profileId);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [mode, setMode] = useState<'upload' | 'record'>('upload');
|
||||||
|
|
||||||
const form = useForm<SampleFormValues>({
|
const form = useForm<SampleFormValues>({
|
||||||
resolver: zodResolver(sampleSchema),
|
resolver: zodResolver(sampleSchema),
|
||||||
@@ -50,6 +59,71 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedFile = form.watch('file');
|
||||||
|
|
||||||
|
const {
|
||||||
|
isRecording,
|
||||||
|
duration,
|
||||||
|
error: recordingError,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
cancelRecording,
|
||||||
|
} = useAudioRecording({
|
||||||
|
maxDurationSeconds: 30,
|
||||||
|
onRecordingComplete: (blob) => {
|
||||||
|
// Convert blob to File object
|
||||||
|
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
||||||
|
type: blob.type || 'audio/webm',
|
||||||
|
});
|
||||||
|
form.setValue('file', file, { shouldValidate: true });
|
||||||
|
toast({
|
||||||
|
title: 'Recording complete',
|
||||||
|
description: 'Audio has been recorded successfully.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show recording errors
|
||||||
|
useEffect(() => {
|
||||||
|
if (recordingError) {
|
||||||
|
toast({
|
||||||
|
title: 'Recording error',
|
||||||
|
description: recordingError,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [recordingError, toast]);
|
||||||
|
|
||||||
|
async function handleTranscribe() {
|
||||||
|
const file = form.getValues('file');
|
||||||
|
if (!file) {
|
||||||
|
toast({
|
||||||
|
title: 'No file selected',
|
||||||
|
description: 'Please select an audio file first.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const language = profile?.language as 'en' | 'zh' | undefined;
|
||||||
|
const result = await transcribe.mutateAsync({ file, language });
|
||||||
|
|
||||||
|
form.setValue('referenceText', result.text, { shouldValidate: true });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Transcription complete',
|
||||||
|
description: 'Audio has been transcribed successfully.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Transcription failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Failed to transcribe audio',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit(data: SampleFormValues) {
|
async function onSubmit(data: SampleFormValues) {
|
||||||
try {
|
try {
|
||||||
await addSample.mutateAsync({
|
await addSample.mutateAsync({
|
||||||
@@ -63,8 +137,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
description: 'Audio sample has been added successfully.',
|
description: 'Audio sample has been added successfully.',
|
||||||
});
|
});
|
||||||
|
|
||||||
form.reset();
|
handleOpenChange(false);
|
||||||
onOpenChange(false);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@@ -74,8 +147,25 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(newOpen: boolean) {
|
||||||
|
if (!newOpen) {
|
||||||
|
form.reset();
|
||||||
|
setMode('upload');
|
||||||
|
if (isRecording) {
|
||||||
|
cancelRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onOpenChange(newOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelRecording() {
|
||||||
|
cancelRecording();
|
||||||
|
// Reset file field by clearing the input
|
||||||
|
form.resetField('file');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Audio Sample</DialogTitle>
|
<DialogTitle>Add Audio Sample</DialogTitle>
|
||||||
@@ -86,6 +176,19 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record')}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Upload
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
Record
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="upload" className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="file"
|
name="file"
|
||||||
@@ -105,13 +208,124 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
}}
|
}}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
{selectedFile && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTranscribe}
|
||||||
|
disabled={transcribe.isPending}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Supported formats: WAV, MP3, M4A</FormDescription>
|
<FormDescription>
|
||||||
|
Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically
|
||||||
|
extract text from the audio.
|
||||||
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="record" className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="file"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Record Audio</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!isRecording && !selectedFile && (
|
||||||
|
<div className="flex flex-col items-center gap-4 p-6 border-2 border-dashed rounded-lg">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={startRecording}
|
||||||
|
size="lg"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Mic className="h-5 w-5" />
|
||||||
|
Start Recording
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Click to start recording. Maximum duration: 30 seconds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRecording && (
|
||||||
|
<div className="flex flex-col items-center gap-4 p-6 border-2 border-destructive rounded-lg bg-destructive/5">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
||||||
|
<span className="text-lg font-mono font-semibold">
|
||||||
|
{formatAudioDuration(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={stopRecording}
|
||||||
|
variant="destructive"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
Stop Recording
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Recording in progress... ({formatAudioDuration(30 - duration)}{' '}
|
||||||
|
remaining)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedFile && !isRecording && (
|
||||||
|
<div className="flex flex-col items-center gap-4 p-6 border-2 border-primary rounded-lg bg-primary/5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mic className="h-5 w-5 text-primary" />
|
||||||
|
<span className="font-medium">Recording complete</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
File: {selectedFile.name}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTranscribe}
|
||||||
|
disabled={transcribe.isPending}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancelRecording}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Record Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Record audio directly from your microphone. Maximum duration is 30 seconds.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -135,7 +349,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={addSample.isPending}>
|
<Button type="submit" disabled={addSample.isPending}>
|
||||||
|
|||||||
@@ -4,22 +4,22 @@ import * as React from 'react';
|
|||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
default: 'bg-accent text-accent-foreground hover:bg-accent/90',
|
||||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: 'text-accent underline-offset-4 hover:underline',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-10 px-4 py-2',
|
default: 'h-10 px-4 py-2',
|
||||||
sm: 'h-9 rounded-md px-3',
|
sm: 'h-9 rounded-full px-3',
|
||||||
lg: 'h-11 rounded-md px-8',
|
lg: 'h-11 rounded-full px-8',
|
||||||
icon: 'h-10 w-10',
|
icon: 'h-10 w-10 rounded-full',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||||
|
({ className, onCheckedChange, ...props }, ref) => {
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (onCheckedChange) {
|
||||||
|
onCheckedChange(e.target.checked);
|
||||||
|
}
|
||||||
|
// Call original onChange if provided
|
||||||
|
if (props.onChange) {
|
||||||
|
props.onChange(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Checkbox.displayName = 'Checkbox';
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
export interface CircleButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CircleButton = React.forwardRef<HTMLButtonElement, CircleButtonProps>(
|
||||||
|
({ className, icon: Icon, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7 rounded-full flex items-center justify-center flex-shrink-0',
|
||||||
|
'hover:bg-muted transition-colors',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
CircleButton.displayName = 'CircleButton';
|
||||||
|
|
||||||
|
export { CircleButton };
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { MoreHorizontal } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<MoreHorizontal className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<MoreHorizontal className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />;
|
||||||
|
};
|
||||||
|
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 translate-x-0.5" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
));
|
||||||
|
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Slider };
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { check, type Update } from '@tauri-apps/plugin-updater';
|
||||||
|
import { relaunch } from '@tauri-apps/plugin-process';
|
||||||
|
|
||||||
|
export interface UpdateStatus {
|
||||||
|
checking: boolean;
|
||||||
|
available: boolean;
|
||||||
|
version?: string;
|
||||||
|
downloading: boolean;
|
||||||
|
installing: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTauri = () => {
|
||||||
|
return '__TAURI_INTERNALS__' in window;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAutoUpdater(checkOnMount = false) {
|
||||||
|
const [status, setStatus] = useState<UpdateStatus>({
|
||||||
|
checking: false,
|
||||||
|
available: false,
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [update, setUpdate] = useState<Update | null>(null);
|
||||||
|
|
||||||
|
const checkForUpdates = async () => {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus((prev) => ({ ...prev, checking: true, error: undefined }));
|
||||||
|
|
||||||
|
const foundUpdate = await check();
|
||||||
|
|
||||||
|
if (foundUpdate?.available) {
|
||||||
|
setUpdate(foundUpdate);
|
||||||
|
setStatus({
|
||||||
|
checking: false,
|
||||||
|
available: true,
|
||||||
|
version: foundUpdate.version,
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setStatus({
|
||||||
|
checking: false,
|
||||||
|
available: false,
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus({
|
||||||
|
checking: false,
|
||||||
|
available: false,
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadAndInstall = async () => {
|
||||||
|
if (!update || !isTauri()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus((prev) => ({ ...prev, downloading: true, error: undefined }));
|
||||||
|
|
||||||
|
await update.downloadAndInstall((event) => {
|
||||||
|
switch (event.event) {
|
||||||
|
case 'Started':
|
||||||
|
setStatus((prev) => ({ ...prev, downloading: true }));
|
||||||
|
break;
|
||||||
|
case 'Progress':
|
||||||
|
console.log(`Downloaded ${event.data.chunkLength} bytes`);
|
||||||
|
break;
|
||||||
|
case 'Finished':
|
||||||
|
setStatus((prev) => ({
|
||||||
|
...prev,
|
||||||
|
downloading: false,
|
||||||
|
installing: true,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await relaunch();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus((prev) => ({
|
||||||
|
...prev,
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to install update',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (checkOnMount && isTauri()) {
|
||||||
|
checkForUpdates();
|
||||||
|
}
|
||||||
|
}, [checkOnMount]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
checkForUpdates,
|
||||||
|
downloadAndInstall,
|
||||||
|
};
|
||||||
|
}
|
||||||
+31
-19
@@ -42,26 +42,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 95%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 222.2 84% 4.9%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 97%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 97%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
--primary: 222.2 47.4% 11.2%;
|
--primary: 222.2 47.4% 11.2%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 210 40% 92%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 210 40% 90%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 43 50% 50%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 214.3 31.8% 85%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 214.3 31.8% 88%;
|
||||||
--ring: 222.2 84% 4.9%;
|
--ring: 222.2 84% 4.9%;
|
||||||
--sidebar: 0 0% 98%;
|
--sidebar: 0 0% 92%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--chart-1: 12 76% 61%;
|
--chart-1: 12 76% 61%;
|
||||||
--chart-2: 173 58% 39%;
|
--chart-2: 173 58% 39%;
|
||||||
@@ -71,26 +71,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 0 0% 8%;
|
--background: 0 0% 6%;
|
||||||
--foreground: 0 0% 95%;
|
--foreground: 0 0% 95%;
|
||||||
--card: 0 0% 10%;
|
--card: 0 0% 8%;
|
||||||
--card-foreground: 0 0% 95%;
|
--card-foreground: 0 0% 95%;
|
||||||
--popover: 0 0% 10%;
|
--popover: 0 0% 8%;
|
||||||
--popover-foreground: 0 0% 95%;
|
--popover-foreground: 0 0% 95%;
|
||||||
--primary: 0 0% 20%;
|
--primary: 0 0% 18%;
|
||||||
--primary-foreground: 0 0% 95%;
|
--primary-foreground: 0 0% 95%;
|
||||||
--secondary: 0 0% 15%;
|
--secondary: 0 0% 12%;
|
||||||
--secondary-foreground: 0 0% 95%;
|
--secondary-foreground: 0 0% 95%;
|
||||||
--muted: 0 0% 15%;
|
--muted: 0 0% 12%;
|
||||||
--muted-foreground: 0 0% 60%;
|
--muted-foreground: 0 0% 60%;
|
||||||
--accent: 0 0% 15%;
|
--accent: 43 50% 45%;
|
||||||
--accent-foreground: 0 0% 95%;
|
--accent-foreground: 0 0% 95%;
|
||||||
--destructive: 0 62.8% 50%;
|
--destructive: 0 62.8% 50%;
|
||||||
--destructive-foreground: 0 0% 95%;
|
--destructive-foreground: 0 0% 95%;
|
||||||
--border: 0 0% 15%;
|
--border: 0 0% 12%;
|
||||||
--input: 0 0% 15%;
|
--input: 0 0% 12%;
|
||||||
--ring: 0 0% 40%;
|
--ring: 0 0% 40%;
|
||||||
--sidebar: 0 0% 6%;
|
--sidebar: 0 0% 4%;
|
||||||
--chart-1: 220 70% 50%;
|
--chart-1: 220 70% 50%;
|
||||||
--chart-2: 160 60% 45%;
|
--chart-2: 160 60% 45%;
|
||||||
--chart-3: 30 80% 55%;
|
--chart-3: 30 80% 55%;
|
||||||
@@ -102,9 +102,21 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
@apply overflow-hidden;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
/* Hide scrollbars globally */
|
||||||
|
* {
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari, Opera */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|||||||
@@ -20,10 +20,7 @@ class ApiClient {
|
|||||||
return serverUrl;
|
return serverUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(
|
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||||
endpoint: string,
|
|
||||||
options?: RequestInit,
|
|
||||||
): Promise<T> {
|
|
||||||
const url = `${this.getBaseUrl()}${endpoint}`;
|
const url = `${this.getBaseUrl()}${endpoint}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
@@ -64,10 +61,7 @@ class ApiClient {
|
|||||||
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`);
|
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProfile(
|
async updateProfile(profileId: string, data: VoiceProfileCreate): Promise<VoiceProfileResponse> {
|
||||||
profileId: string,
|
|
||||||
data: VoiceProfileCreate,
|
|
||||||
): Promise<VoiceProfileResponse> {
|
|
||||||
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`, {
|
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
@@ -105,12 +99,8 @@ class ApiClient {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async listProfileSamples(
|
async listProfileSamples(profileId: string): Promise<ProfileSampleResponse[]> {
|
||||||
profileId: string,
|
return this.request<ProfileSampleResponse[]>(`/profiles/${profileId}/samples`);
|
||||||
): Promise<ProfileSampleResponse[]> {
|
|
||||||
return this.request<ProfileSampleResponse[]>(
|
|
||||||
`/profiles/${profileId}/samples`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteProfileSample(sampleId: string): Promise<void> {
|
async deleteProfileSample(sampleId: string): Promise<void> {
|
||||||
@@ -120,9 +110,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generation
|
// Generation
|
||||||
async generateSpeech(
|
async generateSpeech(data: GenerationRequest): Promise<GenerationResponse> {
|
||||||
data: GenerationRequest,
|
|
||||||
): Promise<GenerationResponse> {
|
|
||||||
return this.request<GenerationResponse>('/generate', {
|
return this.request<GenerationResponse>('/generate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
@@ -159,10 +147,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transcription
|
// Transcription
|
||||||
async transcribeAudio(
|
async transcribeAudio(file: File, language?: 'en' | 'zh'): Promise<TranscriptionResponse> {
|
||||||
file: File,
|
|
||||||
language?: 'en' | 'zh',
|
|
||||||
): Promise<TranscriptionResponse> {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
if (language) {
|
if (language) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export class CancelError extends Error {
|
export class CancelError extends Error {
|
||||||
|
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'CancelError';
|
this.name = 'CancelError';
|
||||||
@@ -35,8 +34,8 @@ export class CancelablePromise<T> implements Promise<T> {
|
|||||||
executor: (
|
executor: (
|
||||||
resolve: (value: T | PromiseLike<T>) => void,
|
resolve: (value: T | PromiseLike<T>) => void,
|
||||||
reject: (reason?: any) => void,
|
reject: (reason?: any) => void,
|
||||||
onCancel: OnCancel
|
onCancel: OnCancel,
|
||||||
) => void
|
) => void,
|
||||||
) {
|
) {
|
||||||
this.#isResolved = false;
|
this.#isResolved = false;
|
||||||
this.#isRejected = false;
|
this.#isRejected = false;
|
||||||
@@ -86,18 +85,18 @@ export class CancelablePromise<T> implements Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get [Symbol.toStringTag]() {
|
get [Symbol.toStringTag]() {
|
||||||
return "Cancellable Promise";
|
return 'Cancellable Promise';
|
||||||
}
|
}
|
||||||
|
|
||||||
public then<TResult1 = T, TResult2 = never>(
|
public then<TResult1 = T, TResult2 = never>(
|
||||||
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
|
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
|
||||||
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
|
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
|
||||||
): Promise<TResult1 | TResult2> {
|
): Promise<TResult1 | TResult2> {
|
||||||
return this.#promise.then(onFulfilled, onRejected);
|
return this.#promise.then(onFulfilled, onRejected);
|
||||||
}
|
}
|
||||||
|
|
||||||
public catch<TResult = never>(
|
public catch<TResult = never>(
|
||||||
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
|
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null,
|
||||||
): Promise<T | TResult> {
|
): Promise<T | TResult> {
|
||||||
return this.#promise.catch(onRejected);
|
return this.#promise.catch(onRejected);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { CancelablePromise } from './CancelablePromise';
|
|||||||
import type { OnCancel } from './CancelablePromise';
|
import type { OnCancel } from './CancelablePromise';
|
||||||
import type { OpenAPIConfig } from './OpenAPI';
|
import type { OpenAPIConfig } from './OpenAPI';
|
||||||
|
|
||||||
export const isDefined = <T>(value: T | null | undefined): value is Exclude<T, null | undefined> => {
|
export const isDefined = <T>(
|
||||||
|
value: T | null | undefined,
|
||||||
|
): value is Exclude<T, null | undefined> => {
|
||||||
return value !== undefined && value !== null;
|
return value !== undefined && value !== null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ export const getQueryString = (params: Record<string, any>): string => {
|
|||||||
const process = (key: string, value: any) => {
|
const process = (key: string, value: any) => {
|
||||||
if (isDefined(value)) {
|
if (isDefined(value)) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
value.forEach(v => {
|
value.forEach((v) => {
|
||||||
process(key, v);
|
process(key, v);
|
||||||
});
|
});
|
||||||
} else if (typeof value === 'object') {
|
} else if (typeof value === 'object') {
|
||||||
@@ -116,7 +118,7 @@ export const getFormData = (options: ApiRequestOptions): FormData | undefined =>
|
|||||||
.filter(([_, value]) => isDefined(value))
|
.filter(([_, value]) => isDefined(value))
|
||||||
.forEach(([key, value]) => {
|
.forEach(([key, value]) => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
value.forEach(v => process(key, v));
|
value.forEach((v) => process(key, v));
|
||||||
} else {
|
} else {
|
||||||
process(key, value);
|
process(key, value);
|
||||||
}
|
}
|
||||||
@@ -129,14 +131,20 @@ export const getFormData = (options: ApiRequestOptions): FormData | undefined =>
|
|||||||
|
|
||||||
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
|
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
|
||||||
|
|
||||||
export const resolve = async <T>(options: ApiRequestOptions, resolver?: T | Resolver<T>): Promise<T | undefined> => {
|
export const resolve = async <T>(
|
||||||
|
options: ApiRequestOptions,
|
||||||
|
resolver?: T | Resolver<T>,
|
||||||
|
): Promise<T | undefined> => {
|
||||||
if (typeof resolver === 'function') {
|
if (typeof resolver === 'function') {
|
||||||
return (resolver as Resolver<T>)(options);
|
return (resolver as Resolver<T>)(options);
|
||||||
}
|
}
|
||||||
return resolver;
|
return resolver;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Promise<Headers> => {
|
export const getHeaders = async (
|
||||||
|
config: OpenAPIConfig,
|
||||||
|
options: ApiRequestOptions,
|
||||||
|
): Promise<Headers> => {
|
||||||
const [token, username, password, additionalHeaders] = await Promise.all([
|
const [token, username, password, additionalHeaders] = await Promise.all([
|
||||||
resolve(options, config.TOKEN),
|
resolve(options, config.TOKEN),
|
||||||
resolve(options, config.USERNAME),
|
resolve(options, config.USERNAME),
|
||||||
@@ -150,10 +158,13 @@ export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptio
|
|||||||
...options.headers,
|
...options.headers,
|
||||||
})
|
})
|
||||||
.filter(([_, value]) => isDefined(value))
|
.filter(([_, value]) => isDefined(value))
|
||||||
.reduce((headers, [key, value]) => ({
|
.reduce(
|
||||||
|
(headers, [key, value]) => ({
|
||||||
...headers,
|
...headers,
|
||||||
[key]: String(value),
|
[key]: String(value),
|
||||||
}), {} as Record<string, string>);
|
}),
|
||||||
|
{} as Record<string, string>,
|
||||||
|
);
|
||||||
|
|
||||||
if (isStringWithValue(token)) {
|
if (isStringWithValue(token)) {
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
@@ -182,7 +193,7 @@ export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptio
|
|||||||
export const getRequestBody = (options: ApiRequestOptions): any => {
|
export const getRequestBody = (options: ApiRequestOptions): any => {
|
||||||
if (options.body !== undefined) {
|
if (options.body !== undefined) {
|
||||||
if (options.mediaType?.includes('/json')) {
|
if (options.mediaType?.includes('/json')) {
|
||||||
return JSON.stringify(options.body)
|
return JSON.stringify(options.body);
|
||||||
} else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) {
|
} else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) {
|
||||||
return options.body;
|
return options.body;
|
||||||
} else {
|
} else {
|
||||||
@@ -199,7 +210,7 @@ export const sendRequest = async (
|
|||||||
body: any,
|
body: any,
|
||||||
formData: FormData | undefined,
|
formData: FormData | undefined,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
onCancel: OnCancel
|
onCancel: OnCancel,
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -219,7 +230,10 @@ export const sendRequest = async (
|
|||||||
return await fetch(url, request);
|
return await fetch(url, request);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getResponseHeader = (response: Response, responseHeader?: string): string | undefined => {
|
export const getResponseHeader = (
|
||||||
|
response: Response,
|
||||||
|
responseHeader?: string,
|
||||||
|
): string | undefined => {
|
||||||
if (responseHeader) {
|
if (responseHeader) {
|
||||||
const content = response.headers.get(responseHeader);
|
const content = response.headers.get(responseHeader);
|
||||||
if (isString(content)) {
|
if (isString(content)) {
|
||||||
@@ -234,8 +248,8 @@ export const getResponseBody = async (response: Response): Promise<any> => {
|
|||||||
try {
|
try {
|
||||||
const contentType = response.headers.get('Content-Type');
|
const contentType = response.headers.get('Content-Type');
|
||||||
if (contentType) {
|
if (contentType) {
|
||||||
const jsonTypes = ['application/json', 'application/problem+json']
|
const jsonTypes = ['application/json', 'application/problem+json'];
|
||||||
const isJSON = jsonTypes.some(type => contentType.toLowerCase().startsWith(type));
|
const isJSON = jsonTypes.some((type) => contentType.toLowerCase().startsWith(type));
|
||||||
if (isJSON) {
|
if (isJSON) {
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} else {
|
} else {
|
||||||
@@ -259,7 +273,7 @@ export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult):
|
|||||||
502: 'Bad Gateway',
|
502: 'Bad Gateway',
|
||||||
503: 'Service Unavailable',
|
503: 'Service Unavailable',
|
||||||
...options.errors,
|
...options.errors,
|
||||||
}
|
};
|
||||||
|
|
||||||
const error = errors[result.status];
|
const error = errors[result.status];
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -277,8 +291,10 @@ export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult):
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
throw new ApiError(options, result,
|
throw new ApiError(
|
||||||
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`
|
options,
|
||||||
|
result,
|
||||||
|
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -290,7 +306,10 @@ export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult):
|
|||||||
* @returns CancelablePromise<T>
|
* @returns CancelablePromise<T>
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions): CancelablePromise<T> => {
|
export const request = <T>(
|
||||||
|
config: OpenAPIConfig,
|
||||||
|
options: ApiRequestOptions,
|
||||||
|
): CancelablePromise<T> => {
|
||||||
return new CancelablePromise(async (resolve, reject, onCancel) => {
|
return new CancelablePromise(async (resolve, reject, onCancel) => {
|
||||||
try {
|
try {
|
||||||
const url = getUrl(config, options);
|
const url = getUrl(config, options);
|
||||||
|
|||||||
@@ -6,4 +6,3 @@ export type Body_add_profile_sample_profiles__profile_id__samples_post = {
|
|||||||
file: Blob;
|
file: Blob;
|
||||||
reference_text: string;
|
reference_text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,5 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export type Body_transcribe_audio_transcribe_post = {
|
export type Body_transcribe_audio_transcribe_post = {
|
||||||
file: Blob;
|
file: Blob;
|
||||||
language?: (string | null);
|
language?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type GenerationRequest = {
|
|||||||
profile_id: string;
|
profile_id: string;
|
||||||
text: string;
|
text: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
seed?: (number | null);
|
seed?: number | null;
|
||||||
model_size?: (string | null);
|
model_size?: string | null;
|
||||||
|
instruct?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type GenerationResponse = {
|
|||||||
language: string;
|
language: string;
|
||||||
audio_path: string;
|
audio_path: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
seed: (number | null);
|
seed: number | null;
|
||||||
|
instruct: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,3 @@ import type { ValidationError } from './ValidationError';
|
|||||||
export type HTTPValidationError = {
|
export type HTTPValidationError = {
|
||||||
detail?: Array<ValidationError>;
|
detail?: Array<ValidationError>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,8 @@
|
|||||||
export type HealthResponse = {
|
export type HealthResponse = {
|
||||||
status: string;
|
status: string;
|
||||||
model_loaded: boolean;
|
model_loaded: boolean;
|
||||||
model_downloaded?: (boolean | null);
|
model_downloaded?: boolean | null;
|
||||||
model_size?: (string | null);
|
model_size?: string | null;
|
||||||
gpu_available: boolean;
|
gpu_available: boolean;
|
||||||
vram_used_mb?: (number | null);
|
vram_used_mb?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,4 +10,3 @@ export type HistoryListResponse = {
|
|||||||
items: Array<HistoryResponse>;
|
items: Array<HistoryResponse>;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export type HistoryResponse = {
|
|||||||
language: string;
|
language: string;
|
||||||
audio_path: string;
|
audio_path: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
seed: (number | null);
|
seed: number | null;
|
||||||
|
instruct: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,4 +8,3 @@
|
|||||||
export type ModelDownloadRequest = {
|
export type ModelDownloadRequest = {
|
||||||
model_name: string;
|
model_name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export type ModelStatus = {
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
size_mb?: (number | null);
|
size_mb?: number | null;
|
||||||
loaded?: boolean;
|
loaded?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,4 +9,3 @@ import type { ModelStatus } from './ModelStatus';
|
|||||||
export type ModelStatusListResponse = {
|
export type ModelStatusListResponse = {
|
||||||
models: Array<ModelStatus>;
|
models: Array<ModelStatus>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,4 +11,3 @@ export type ProfileSampleResponse = {
|
|||||||
audio_path: string;
|
audio_path: string;
|
||||||
reference_text: string;
|
reference_text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,4 +9,3 @@ export type TranscriptionResponse = {
|
|||||||
text: string;
|
text: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export type ValidationError = {
|
export type ValidationError = {
|
||||||
loc: Array<(string | number)>;
|
loc: Array<string | number>;
|
||||||
msg: string;
|
msg: string;
|
||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
*/
|
*/
|
||||||
export type VoiceProfileCreate = {
|
export type VoiceProfileCreate = {
|
||||||
name: string;
|
name: string;
|
||||||
description?: (string | null);
|
description?: string | null;
|
||||||
language?: string;
|
language?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,8 @@
|
|||||||
export type VoiceProfileResponse = {
|
export type VoiceProfileResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: (string | null);
|
description: string | null;
|
||||||
language: string;
|
language: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ export const $Body_transcribe_audio_transcribe_post = {
|
|||||||
},
|
},
|
||||||
language: {
|
language: {
|
||||||
type: 'any-of',
|
type: 'any-of',
|
||||||
contains: [{
|
contains: [
|
||||||
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
type: 'null',
|
type: 'null',
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -21,20 +21,26 @@ export const $GenerationRequest = {
|
|||||||
},
|
},
|
||||||
seed: {
|
seed: {
|
||||||
type: 'any-of',
|
type: 'any-of',
|
||||||
contains: [{
|
contains: [
|
||||||
|
{
|
||||||
type: 'number',
|
type: 'number',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
type: 'null',
|
type: 'null',
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
model_size: {
|
model_size: {
|
||||||
type: 'any-of',
|
type: 'any-of',
|
||||||
contains: [{
|
contains: [
|
||||||
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
pattern: '^(1\\.7B|0\\.6B)$',
|
pattern: '^(1\\.7B|0\\.6B)$',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
type: 'null',
|
type: 'null',
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -31,11 +31,14 @@ export const $GenerationResponse = {
|
|||||||
},
|
},
|
||||||
seed: {
|
seed: {
|
||||||
type: 'any-of',
|
type: 'any-of',
|
||||||
contains: [{
|
contains: [
|
||||||
|
{
|
||||||
type: 'number',
|
type: 'number',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
type: 'null',
|
type: 'null',
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
},
|
||||||
created_at: {
|
created_at: {
|
||||||
|
|||||||
@@ -15,19 +15,25 @@ export const $HealthResponse = {
|
|||||||
},
|
},
|
||||||
model_downloaded: {
|
model_downloaded: {
|
||||||
type: 'any-of',
|
type: 'any-of',
|
||||||
contains: [{
|
contains: [
|
||||||
|
{
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
type: 'null',
|
type: 'null',
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
model_size: {
|
model_size: {
|
||||||
type: 'any-of',
|
type: 'any-of',
|
||||||
contains: [{
|
contains: [
|
||||||
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
type: 'null',
|
type: 'null',
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
gpu_available: {
|
gpu_available: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
@@ -35,11 +41,14 @@ export const $HealthResponse = {
|
|||||||
},
|
},
|
||||||
vram_used_mb: {
|
vram_used_mb: {
|
||||||
type: 'any-of',
|
type: 'any-of',
|
||||||
contains: [{
|
contains: [
|
||||||
|
{
|
||||||
type: 'number',
|
type: 'number',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
type: 'null',
|
type: 'null',
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ export const $HistoryResponse = {
|
|||||||
},
|
},
|
||||||
seed: {
|
seed: {
|
||||||
type: 'any-of',
|
type: 'any-of',
|
||||||
contains: [{
|
contains: [
|
||||||
|
{
|
||||||
type: 'number',
|
type: 'number',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
type: 'null',
|
type: 'null',
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
},
|
||||||
created_at: {
|
created_at: {
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ export const $ModelStatus = {
|
|||||||
},
|
},
|
||||||
size_mb: {
|
size_mb: {
|
||||||
type: 'any-of',
|
type: 'any-of',
|
||||||
contains: [{
|
contains: [
|
||||||
|
{
|
||||||
type: 'number',
|
type: 'number',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
type: 'null',
|
type: 'null',
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
loaded: {
|
loaded: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ export const $ValidationError = {
|
|||||||
type: 'array',
|
type: 'array',
|
||||||
contains: {
|
contains: {
|
||||||
type: 'any-of',
|
type: 'any-of',
|
||||||
contains: [{
|
contains: [
|
||||||
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
type: 'number',
|
type: 'number',
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ export const $VoiceProfileCreate = {
|
|||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
type: 'any-of',
|
type: 'any-of',
|
||||||
contains: [{
|
contains: [
|
||||||
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
maxLength: 500,
|
maxLength: 500,
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
type: 'null',
|
type: 'null',
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
language: {
|
language: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ export const $VoiceProfileResponse = {
|
|||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
type: 'any-of',
|
type: 'any-of',
|
||||||
contains: [{
|
contains: [
|
||||||
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
type: 'null',
|
type: 'null',
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
},
|
||||||
language: {
|
language: {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export class DefaultService {
|
|||||||
public static createProfileProfilesPost({
|
public static createProfileProfilesPost({
|
||||||
requestBody,
|
requestBody,
|
||||||
}: {
|
}: {
|
||||||
requestBody: VoiceProfileCreate,
|
requestBody: VoiceProfileCreate;
|
||||||
}): CancelablePromise<VoiceProfileResponse> {
|
}): CancelablePromise<VoiceProfileResponse> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -85,13 +85,13 @@ export class DefaultService {
|
|||||||
public static getProfileProfilesProfileIdGet({
|
public static getProfileProfilesProfileIdGet({
|
||||||
profileId,
|
profileId,
|
||||||
}: {
|
}: {
|
||||||
profileId: string,
|
profileId: string;
|
||||||
}): CancelablePromise<VoiceProfileResponse> {
|
}): CancelablePromise<VoiceProfileResponse> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/profiles/{profile_id}',
|
url: '/profiles/{profile_id}',
|
||||||
path: {
|
path: {
|
||||||
'profile_id': profileId,
|
profile_id: profileId,
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
@@ -108,14 +108,14 @@ export class DefaultService {
|
|||||||
profileId,
|
profileId,
|
||||||
requestBody,
|
requestBody,
|
||||||
}: {
|
}: {
|
||||||
profileId: string,
|
profileId: string;
|
||||||
requestBody: VoiceProfileCreate,
|
requestBody: VoiceProfileCreate;
|
||||||
}): CancelablePromise<VoiceProfileResponse> {
|
}): CancelablePromise<VoiceProfileResponse> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
url: '/profiles/{profile_id}',
|
url: '/profiles/{profile_id}',
|
||||||
path: {
|
path: {
|
||||||
'profile_id': profileId,
|
profile_id: profileId,
|
||||||
},
|
},
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
mediaType: 'application/json',
|
mediaType: 'application/json',
|
||||||
@@ -133,13 +133,13 @@ export class DefaultService {
|
|||||||
public static deleteProfileProfilesProfileIdDelete({
|
public static deleteProfileProfilesProfileIdDelete({
|
||||||
profileId,
|
profileId,
|
||||||
}: {
|
}: {
|
||||||
profileId: string,
|
profileId: string;
|
||||||
}): CancelablePromise<any> {
|
}): CancelablePromise<any> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
url: '/profiles/{profile_id}',
|
url: '/profiles/{profile_id}',
|
||||||
path: {
|
path: {
|
||||||
'profile_id': profileId,
|
profile_id: profileId,
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
@@ -156,14 +156,14 @@ export class DefaultService {
|
|||||||
profileId,
|
profileId,
|
||||||
formData,
|
formData,
|
||||||
}: {
|
}: {
|
||||||
profileId: string,
|
profileId: string;
|
||||||
formData: Body_add_profile_sample_profiles__profile_id__samples_post,
|
formData: Body_add_profile_sample_profiles__profile_id__samples_post;
|
||||||
}): CancelablePromise<ProfileSampleResponse> {
|
}): CancelablePromise<ProfileSampleResponse> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/profiles/{profile_id}/samples',
|
url: '/profiles/{profile_id}/samples',
|
||||||
path: {
|
path: {
|
||||||
'profile_id': profileId,
|
profile_id: profileId,
|
||||||
},
|
},
|
||||||
formData: formData,
|
formData: formData,
|
||||||
mediaType: 'multipart/form-data',
|
mediaType: 'multipart/form-data',
|
||||||
@@ -181,13 +181,13 @@ export class DefaultService {
|
|||||||
public static getProfileSamplesProfilesProfileIdSamplesGet({
|
public static getProfileSamplesProfilesProfileIdSamplesGet({
|
||||||
profileId,
|
profileId,
|
||||||
}: {
|
}: {
|
||||||
profileId: string,
|
profileId: string;
|
||||||
}): CancelablePromise<Array<ProfileSampleResponse>> {
|
}): CancelablePromise<Array<ProfileSampleResponse>> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/profiles/{profile_id}/samples',
|
url: '/profiles/{profile_id}/samples',
|
||||||
path: {
|
path: {
|
||||||
'profile_id': profileId,
|
profile_id: profileId,
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
@@ -203,13 +203,13 @@ export class DefaultService {
|
|||||||
public static deleteProfileSampleProfilesSamplesSampleIdDelete({
|
public static deleteProfileSampleProfilesSamplesSampleIdDelete({
|
||||||
sampleId,
|
sampleId,
|
||||||
}: {
|
}: {
|
||||||
sampleId: string,
|
sampleId: string;
|
||||||
}): CancelablePromise<any> {
|
}): CancelablePromise<any> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
url: '/profiles/samples/{sample_id}',
|
url: '/profiles/samples/{sample_id}',
|
||||||
path: {
|
path: {
|
||||||
'sample_id': sampleId,
|
sample_id: sampleId,
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
@@ -225,7 +225,7 @@ export class DefaultService {
|
|||||||
public static generateSpeechGeneratePost({
|
public static generateSpeechGeneratePost({
|
||||||
requestBody,
|
requestBody,
|
||||||
}: {
|
}: {
|
||||||
requestBody: GenerationRequest,
|
requestBody: GenerationRequest;
|
||||||
}): CancelablePromise<GenerationResponse> {
|
}): CancelablePromise<GenerationResponse> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -249,19 +249,19 @@ export class DefaultService {
|
|||||||
limit = 50,
|
limit = 50,
|
||||||
offset,
|
offset,
|
||||||
}: {
|
}: {
|
||||||
profileId?: (string | null),
|
profileId?: string | null;
|
||||||
search?: (string | null),
|
search?: string | null;
|
||||||
limit?: number,
|
limit?: number;
|
||||||
offset?: number,
|
offset?: number;
|
||||||
}): CancelablePromise<HistoryListResponse> {
|
}): CancelablePromise<HistoryListResponse> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/history',
|
url: '/history',
|
||||||
query: {
|
query: {
|
||||||
'profile_id': profileId,
|
profile_id: profileId,
|
||||||
'search': search,
|
search: search,
|
||||||
'limit': limit,
|
limit: limit,
|
||||||
'offset': offset,
|
offset: offset,
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
@@ -277,13 +277,13 @@ export class DefaultService {
|
|||||||
public static getGenerationHistoryGenerationIdGet({
|
public static getGenerationHistoryGenerationIdGet({
|
||||||
generationId,
|
generationId,
|
||||||
}: {
|
}: {
|
||||||
generationId: string,
|
generationId: string;
|
||||||
}): CancelablePromise<HistoryResponse> {
|
}): CancelablePromise<HistoryResponse> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/history/{generation_id}',
|
url: '/history/{generation_id}',
|
||||||
path: {
|
path: {
|
||||||
'generation_id': generationId,
|
generation_id: generationId,
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
@@ -299,13 +299,13 @@ export class DefaultService {
|
|||||||
public static deleteGenerationHistoryGenerationIdDelete({
|
public static deleteGenerationHistoryGenerationIdDelete({
|
||||||
generationId,
|
generationId,
|
||||||
}: {
|
}: {
|
||||||
generationId: string,
|
generationId: string;
|
||||||
}): CancelablePromise<any> {
|
}): CancelablePromise<any> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
url: '/history/{generation_id}',
|
url: '/history/{generation_id}',
|
||||||
path: {
|
path: {
|
||||||
'generation_id': generationId,
|
generation_id: generationId,
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
@@ -333,7 +333,7 @@ export class DefaultService {
|
|||||||
public static transcribeAudioTranscribePost({
|
public static transcribeAudioTranscribePost({
|
||||||
formData,
|
formData,
|
||||||
}: {
|
}: {
|
||||||
formData: Body_transcribe_audio_transcribe_post,
|
formData: Body_transcribe_audio_transcribe_post;
|
||||||
}): CancelablePromise<TranscriptionResponse> {
|
}): CancelablePromise<TranscriptionResponse> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -354,13 +354,13 @@ export class DefaultService {
|
|||||||
public static getAudioAudioGenerationIdGet({
|
public static getAudioAudioGenerationIdGet({
|
||||||
generationId,
|
generationId,
|
||||||
}: {
|
}: {
|
||||||
generationId: string,
|
generationId: string;
|
||||||
}): CancelablePromise<any> {
|
}): CancelablePromise<any> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/audio/{generation_id}',
|
url: '/audio/{generation_id}',
|
||||||
path: {
|
path: {
|
||||||
'generation_id': generationId,
|
generation_id: generationId,
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
@@ -376,13 +376,13 @@ export class DefaultService {
|
|||||||
public static loadModelModelsLoadPost({
|
public static loadModelModelsLoadPost({
|
||||||
modelSize = '1.7B',
|
modelSize = '1.7B',
|
||||||
}: {
|
}: {
|
||||||
modelSize?: string,
|
modelSize?: string;
|
||||||
}): CancelablePromise<any> {
|
}): CancelablePromise<any> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/models/load',
|
url: '/models/load',
|
||||||
query: {
|
query: {
|
||||||
'model_size': modelSize,
|
model_size: modelSize,
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
@@ -410,13 +410,13 @@ export class DefaultService {
|
|||||||
public static getModelProgressModelsProgressModelNameGet({
|
public static getModelProgressModelsProgressModelNameGet({
|
||||||
modelName,
|
modelName,
|
||||||
}: {
|
}: {
|
||||||
modelName: string,
|
modelName: string;
|
||||||
}): CancelablePromise<any> {
|
}): CancelablePromise<any> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/models/progress/{model_name}',
|
url: '/models/progress/{model_name}',
|
||||||
path: {
|
path: {
|
||||||
'model_name': modelName,
|
model_name: modelName,
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
@@ -444,7 +444,7 @@ export class DefaultService {
|
|||||||
public static triggerModelDownloadModelsDownloadPost({
|
public static triggerModelDownloadModelsDownloadPost({
|
||||||
requestBody,
|
requestBody,
|
||||||
}: {
|
}: {
|
||||||
requestBody: ModelDownloadRequest,
|
requestBody: ModelDownloadRequest;
|
||||||
}): CancelablePromise<any> {
|
}): CancelablePromise<any> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { isTauri } from '@/lib/tauri';
|
||||||
|
|
||||||
|
interface UseAudioRecordingOptions {
|
||||||
|
maxDurationSeconds?: number;
|
||||||
|
onRecordingComplete?: (blob: Blob) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAudioRecording({
|
||||||
|
maxDurationSeconds = 30,
|
||||||
|
onRecordingComplete,
|
||||||
|
}: UseAudioRecordingOptions = {}) {
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
const startTimeRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const startRecording = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
chunksRef.current = [];
|
||||||
|
setDuration(0);
|
||||||
|
|
||||||
|
// Check if getUserMedia is available
|
||||||
|
// In Tauri, navigator.mediaDevices might not be available immediately
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
const errorMsg =
|
||||||
|
'Navigator API is not available. This might be a Tauri configuration issue.';
|
||||||
|
setError(errorMsg);
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
// Try waiting a bit for Tauri webview to initialize
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
const isTauriEnv = isTauri();
|
||||||
|
console.error('MediaDevices check:', {
|
||||||
|
hasNavigator: typeof navigator !== 'undefined',
|
||||||
|
hasMediaDevices: !!navigator?.mediaDevices,
|
||||||
|
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
|
||||||
|
isTauri: isTauriEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMsg = isTauriEnv
|
||||||
|
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
|
||||||
|
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
|
||||||
|
setError(errorMsg);
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request microphone access
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
streamRef.current = stream;
|
||||||
|
|
||||||
|
// Create MediaRecorder with preferred MIME type
|
||||||
|
const options: MediaRecorderOptions = {
|
||||||
|
mimeType: 'audio/webm;codecs=opus',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fallback to default if webm not supported
|
||||||
|
if (!MediaRecorder.isTypeSupported(options.mimeType!)) {
|
||||||
|
delete options.mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaRecorder = new MediaRecorder(stream, options);
|
||||||
|
mediaRecorderRef.current = mediaRecorder;
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
chunksRef.current.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||||
|
onRecordingComplete?.(blob);
|
||||||
|
|
||||||
|
// Stop all tracks
|
||||||
|
streamRef.current?.getTracks().forEach((track) => {
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
streamRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onerror = (event) => {
|
||||||
|
setError('Recording error occurred');
|
||||||
|
console.error('MediaRecorder error:', event);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start recording
|
||||||
|
mediaRecorder.start(100); // Collect data every 100ms
|
||||||
|
setIsRecording(true);
|
||||||
|
startTimeRef.current = Date.now();
|
||||||
|
|
||||||
|
// Start timer
|
||||||
|
timerRef.current = window.setInterval(() => {
|
||||||
|
if (startTimeRef.current) {
|
||||||
|
const elapsed = (Date.now() - startTimeRef.current) / 1000;
|
||||||
|
setDuration(elapsed);
|
||||||
|
|
||||||
|
// Auto-stop at max duration
|
||||||
|
if (elapsed >= maxDurationSeconds) {
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
setIsRecording(false);
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Failed to access microphone. Please check permissions.';
|
||||||
|
setError(errorMessage);
|
||||||
|
setIsRecording(false);
|
||||||
|
}
|
||||||
|
}, [maxDurationSeconds, onRecordingComplete]);
|
||||||
|
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
setIsRecording(false);
|
||||||
|
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isRecording]);
|
||||||
|
|
||||||
|
const cancelRecording = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current) {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
setIsRecording(false);
|
||||||
|
chunksRef.current = [];
|
||||||
|
setDuration(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop all tracks
|
||||||
|
streamRef.current?.getTracks().forEach((track) => {
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
streamRef.current = null;
|
||||||
|
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
streamRef.current?.getTracks().forEach((track) => {
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRecording,
|
||||||
|
duration,
|
||||||
|
error,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
cancelRecording,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen, emit } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if running in Tauri environment
|
* Check if running in Tauri environment
|
||||||
@@ -45,3 +46,41 @@ export async function stopServer(): Promise<void> {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup window close handler to check setting and stop server if needed
|
||||||
|
*/
|
||||||
|
export async function setupWindowCloseHandler(): Promise<void> {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Listen for window close request from Rust
|
||||||
|
await listen<null>('window-close-requested', async () => {
|
||||||
|
// Import store here to avoid circular dependency
|
||||||
|
const { useServerStore } = await import('@/stores/serverStore');
|
||||||
|
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
||||||
|
|
||||||
|
// Check if server was started by this app instance
|
||||||
|
// In dev mode, serverStartedByApp will be false, so we won't try to stop a separately-run server
|
||||||
|
// We need to access the module-level variable - this is a bit hacky but works
|
||||||
|
// @ts-expect-error - accessing module-level variable from another module
|
||||||
|
const serverStartedByApp = window.__voiceboxServerStartedByApp ?? false;
|
||||||
|
|
||||||
|
if (!keepRunning && serverStartedByApp) {
|
||||||
|
// Stop server before closing (only if we started it)
|
||||||
|
try {
|
||||||
|
await stopServer();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to stop server on close:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit event back to Rust to allow close
|
||||||
|
await emit('window-close-allowed');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to setup window close handler:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,37 +1,55 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
interface PlayerState {
|
interface PlayerState {
|
||||||
currentAudioId: string | null;
|
audioUrl: string | null;
|
||||||
|
audioId: string | null;
|
||||||
|
title: string | null;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
|
isLooping: boolean;
|
||||||
|
|
||||||
setCurrentAudio: (audioId: string | null) => void;
|
setAudio: (url: string, id: string, title?: string) => void;
|
||||||
setIsPlaying: (playing: boolean) => void;
|
setIsPlaying: (playing: boolean) => void;
|
||||||
setCurrentTime: (time: number) => void;
|
setCurrentTime: (time: number) => void;
|
||||||
setDuration: (duration: number) => void;
|
setDuration: (duration: number) => void;
|
||||||
setVolume: (volume: number) => void;
|
setVolume: (volume: number) => void;
|
||||||
|
toggleLoop: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePlayerStore = create<PlayerState>((set) => ({
|
export const usePlayerStore = create<PlayerState>((set) => ({
|
||||||
currentAudioId: null,
|
audioUrl: null,
|
||||||
|
audioId: null,
|
||||||
|
title: null,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
volume: 1,
|
volume: 1,
|
||||||
|
isLooping: false,
|
||||||
|
|
||||||
setCurrentAudio: (audioId) => set({ currentAudioId: audioId, currentTime: 0, isPlaying: false }),
|
setAudio: (url, id, title) =>
|
||||||
|
set({
|
||||||
|
audioUrl: url,
|
||||||
|
audioId: id,
|
||||||
|
title: title || null,
|
||||||
|
currentTime: 0,
|
||||||
|
isPlaying: false,
|
||||||
|
}),
|
||||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||||
setCurrentTime: (time) => set({ currentTime: time }),
|
setCurrentTime: (time) => set({ currentTime: time }),
|
||||||
setDuration: (duration) => set({ duration }),
|
setDuration: (duration) => set({ duration }),
|
||||||
setVolume: (volume) => set({ volume }),
|
setVolume: (volume) => set({ volume }),
|
||||||
|
toggleLoop: () => set((state) => ({ isLooping: !state.isLooping })),
|
||||||
reset: () =>
|
reset: () =>
|
||||||
set({
|
set({
|
||||||
currentAudioId: null,
|
audioUrl: null,
|
||||||
|
audioId: null,
|
||||||
|
title: null,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
|
isLooping: false,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ interface ServerStore {
|
|||||||
|
|
||||||
mode: 'local' | 'remote';
|
mode: 'local' | 'remote';
|
||||||
setMode: (mode: 'local' | 'remote') => void;
|
setMode: (mode: 'local' | 'remote') => void;
|
||||||
|
|
||||||
|
keepServerRunningOnClose: boolean;
|
||||||
|
setKeepServerRunningOnClose: (keepRunning: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useServerStore = create<ServerStore>()(
|
export const useServerStore = create<ServerStore>()(
|
||||||
@@ -23,6 +26,9 @@ export const useServerStore = create<ServerStore>()(
|
|||||||
|
|
||||||
mode: 'local',
|
mode: 'local',
|
||||||
setMode: (mode) => set({ mode }),
|
setMode: (mode) => set({ mode }),
|
||||||
|
|
||||||
|
keepServerRunningOnClose: false,
|
||||||
|
setKeepServerRunningOnClose: (keepRunning) => set({ keepServerRunningOnClose: keepRunning }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'voicebox-server',
|
name: 'voicebox-server',
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ interface UIStore {
|
|||||||
generationDialogOpen: boolean;
|
generationDialogOpen: boolean;
|
||||||
setGenerationDialogOpen: (open: boolean) => void;
|
setGenerationDialogOpen: (open: boolean) => void;
|
||||||
|
|
||||||
|
// Selected profile for generation
|
||||||
|
selectedProfileId: string | null;
|
||||||
|
setSelectedProfileId: (id: string | null) => void;
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
setTheme: (theme: 'light' | 'dark') => void;
|
setTheme: (theme: 'light' | 'dark') => void;
|
||||||
@@ -31,6 +35,9 @@ export const useUIStore = create<UIStore>((set) => ({
|
|||||||
generationDialogOpen: false,
|
generationDialogOpen: false,
|
||||||
setGenerationDialogOpen: (open) => set({ generationDialogOpen: open }),
|
setGenerationDialogOpen: (open) => set({ generationDialogOpen: open }),
|
||||||
|
|
||||||
|
selectedProfileId: null,
|
||||||
|
setSelectedProfileId: (id) => set({ selectedProfileId: id }),
|
||||||
|
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
set({ theme });
|
set({ theme });
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class Generation(Base):
|
|||||||
audio_path = Column(String, nullable=False)
|
audio_path = Column(String, nullable=False)
|
||||||
duration = Column(Float, nullable=False)
|
duration = Column(Float, nullable=False)
|
||||||
seed = Column(Integer)
|
seed = Column(Integer)
|
||||||
|
instruct = Column(Text)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async def create_generation(
|
|||||||
duration: float,
|
duration: float,
|
||||||
seed: Optional[int],
|
seed: Optional[int],
|
||||||
db: Session,
|
db: Session,
|
||||||
|
instruct: Optional[str] = None,
|
||||||
) -> GenerationResponse:
|
) -> GenerationResponse:
|
||||||
"""
|
"""
|
||||||
Create a new generation history entry.
|
Create a new generation history entry.
|
||||||
@@ -40,6 +41,7 @@ async def create_generation(
|
|||||||
duration: Audio duration in seconds
|
duration: Audio duration in seconds
|
||||||
seed: Random seed used (if any)
|
seed: Random seed used (if any)
|
||||||
db: Database session
|
db: Database session
|
||||||
|
instruct: Natural language instruction used (if any)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created generation entry
|
Created generation entry
|
||||||
@@ -52,6 +54,7 @@ async def create_generation(
|
|||||||
audio_path=audio_path,
|
audio_path=audio_path,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
seed=seed,
|
seed=seed,
|
||||||
|
instruct=instruct,
|
||||||
created_at=datetime.utcnow(),
|
created_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,6 +142,7 @@ async def list_generations(
|
|||||||
audio_path=generation.audio_path,
|
audio_path=generation.audio_path,
|
||||||
duration=generation.duration,
|
duration=generation.duration,
|
||||||
seed=generation.seed,
|
seed=generation.seed,
|
||||||
|
instruct=generation.instruct,
|
||||||
created_at=generation.created_at,
|
created_at=generation.created_at,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ async def generate_speech(
|
|||||||
voice_prompt,
|
voice_prompt,
|
||||||
data.language,
|
data.language,
|
||||||
data.seed,
|
data.seed,
|
||||||
|
data.instruct,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate duration
|
# Calculate duration
|
||||||
@@ -280,6 +281,7 @@ async def generate_speech(
|
|||||||
duration=duration,
|
duration=duration,
|
||||||
seed=data.seed,
|
seed=data.seed,
|
||||||
db=db,
|
db=db,
|
||||||
|
instruct=data.instruct,
|
||||||
)
|
)
|
||||||
|
|
||||||
return generation
|
return generation
|
||||||
@@ -342,6 +344,7 @@ async def get_generation(
|
|||||||
audio_path=gen.audio_path,
|
audio_path=gen.audio_path,
|
||||||
duration=gen.duration,
|
duration=gen.duration,
|
||||||
seed=gen.seed,
|
seed=gen.seed,
|
||||||
|
instruct=gen.instruct,
|
||||||
created_at=gen.created_at,
|
created_at=gen.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -706,6 +709,7 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
|||||||
async def startup_event():
|
async def startup_event():
|
||||||
"""Run on application startup."""
|
"""Run on application startup."""
|
||||||
print("voicebox API starting up...")
|
print("voicebox API starting up...")
|
||||||
|
database.init_db()
|
||||||
print(f"Database initialized at {database._db_path}")
|
print(f"Database initialized at {database._db_path}")
|
||||||
print(f"GPU available: {torch.cuda.is_available()}")
|
print(f"GPU available: {torch.cuda.is_available()}")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Database migration script to add instruct column to generations table.
|
||||||
|
|
||||||
|
Run this once to update existing databases:
|
||||||
|
python -m backend.migrate_add_instruct
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
"""Add instruct column to generations table if it doesn't exist."""
|
||||||
|
# Get data directory
|
||||||
|
data_dir = os.environ.get("VOICEBOX_DATA_DIR")
|
||||||
|
if data_dir:
|
||||||
|
db_path = Path(data_dir) / "voicebox.db"
|
||||||
|
else:
|
||||||
|
db_path = Path.cwd() / "data" / "voicebox.db"
|
||||||
|
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"Database not found at {db_path}, skipping migration")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if instruct column already exists
|
||||||
|
cursor.execute("PRAGMA table_info(generations)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'instruct' in columns:
|
||||||
|
print("instruct column already exists, skipping migration")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add instruct column
|
||||||
|
print("Adding instruct column to generations table...")
|
||||||
|
cursor.execute("ALTER TABLE generations ADD COLUMN instruct TEXT")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print("Migration complete!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -50,6 +50,7 @@ class GenerationRequest(BaseModel):
|
|||||||
language: str = Field(default="en", pattern="^(en|zh)$")
|
language: str = Field(default="en", pattern="^(en|zh)$")
|
||||||
seed: Optional[int] = Field(None, ge=0)
|
seed: Optional[int] = Field(None, ge=0)
|
||||||
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$")
|
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B)$")
|
||||||
|
instruct: Optional[str] = Field(None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
class GenerationResponse(BaseModel):
|
class GenerationResponse(BaseModel):
|
||||||
@@ -61,6 +62,7 @@ class GenerationResponse(BaseModel):
|
|||||||
audio_path: str
|
audio_path: str
|
||||||
duration: float
|
duration: float
|
||||||
seed: Optional[int]
|
seed: Optional[int]
|
||||||
|
instruct: Optional[str]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -85,6 +87,7 @@ class HistoryResponse(BaseModel):
|
|||||||
audio_path: str
|
audio_path: str
|
||||||
duration: float
|
duration: float
|
||||||
seed: Optional[int]
|
seed: Optional[int]
|
||||||
|
instruct: Optional[str]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ class TTSModel:
|
|||||||
voice_prompt: dict,
|
voice_prompt: dict,
|
||||||
language: str = "en",
|
language: str = "en",
|
||||||
seed: Optional[int] = None,
|
seed: Optional[int] = None,
|
||||||
|
instruct: Optional[str] = None,
|
||||||
) -> Tuple[np.ndarray, int]:
|
) -> Tuple[np.ndarray, int]:
|
||||||
"""
|
"""
|
||||||
Generate audio from text using voice prompt.
|
Generate audio from text using voice prompt.
|
||||||
@@ -250,6 +251,7 @@ class TTSModel:
|
|||||||
voice_prompt: Voice prompt dictionary from create_voice_prompt
|
voice_prompt: Voice prompt dictionary from create_voice_prompt
|
||||||
language: Language code (en or zh)
|
language: Language code (en or zh)
|
||||||
seed: Random seed for reproducibility
|
seed: Random seed for reproducibility
|
||||||
|
instruct: Natural language instruction for speech delivery control
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (audio_array, sample_rate)
|
Tuple of (audio_array, sample_rate)
|
||||||
@@ -266,6 +268,7 @@ class TTSModel:
|
|||||||
wavs, sample_rate = self.model.generate_voice_clone(
|
wavs, sample_rate = self.model.generate_voice_clone(
|
||||||
text=text,
|
text=text,
|
||||||
voice_clone_prompt=voice_prompt,
|
voice_clone_prompt=voice_prompt,
|
||||||
|
instruct=instruct,
|
||||||
)
|
)
|
||||||
|
|
||||||
audio = wavs[0] # Get first result
|
audio = wavs[0] # Get first result
|
||||||
|
|||||||
@@ -26,15 +26,19 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.1",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-tabs": "^1.1.0",
|
"@radix-ui/react-tabs": "^1.1.0",
|
||||||
"@radix-ui/react-toast": "^1.2.1",
|
"@radix-ui/react-toast": "^1.2.1",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tanstack/react-query-devtools": "^5.0.0",
|
"@tanstack/react-query-devtools": "^5.0.0",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"framer-motion": "^12.29.0",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
@@ -54,6 +58,31 @@
|
|||||||
"vite": "^5.4.0",
|
"vite": "^5.4.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"landing": {
|
||||||
|
"name": "@voicebox/landing",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.316.0",
|
||||||
|
"next": "^16.1.3",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.5",
|
||||||
|
"@types/react": "^18.2.48",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"name": "@voicebox/tauri",
|
"name": "@voicebox/tauri",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -98,6 +127,8 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@alloc/quick-lru": ["@alloc/[email protected]", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
|
|
||||||
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
|
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
|
||||||
|
|
||||||
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="],
|
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="],
|
||||||
@@ -154,6 +185,8 @@
|
|||||||
|
|
||||||
"@biomejs/cli-win32-x64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="],
|
"@biomejs/cli-win32-x64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="],
|
||||||
|
|
||||||
|
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||||
@@ -224,6 +257,56 @@
|
|||||||
|
|
||||||
"@humanwhocodes/object-schema": ["@humanwhocodes/[email protected]", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
|
"@humanwhocodes/object-schema": ["@humanwhocodes/[email protected]", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
|
||||||
|
|
||||||
|
"@img/colour": ["@img/[email protected]", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-arm64": ["@img/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-x64": ["@img/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm64": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-ppc64": ["@img/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-riscv64": ["@img/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-s390x": ["@img/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-x64": ["@img/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": ["@img/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-ppc64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-riscv64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-s390x": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-linuxmusl-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||||
|
|
||||||
|
"@img/sharp-linuxmusl-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||||
|
|
||||||
|
"@img/sharp-wasm32": ["@img/[email protected]", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-arm64": ["@img/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-ia32": ["@img/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-x64": ["@img/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
@@ -234,6 +317,24 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@next/env": ["@next/[email protected]", "", {}, "sha512-gkrXnZyxPUy0Gg6SrPQPccbNVLSP3vmW8LU5dwEttEEC1RwDivk8w4O+sZIjFvPrSICXyhQDCG+y3VmjlJf+9A=="],
|
||||||
|
|
||||||
|
"@next/swc-darwin-arm64": ["@next/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-T8atLKuvk13XQUdVLCv1ZzMPgLPW0+DWWbHSQXs0/3TjPrKNxTmUIhOEaoEyl3Z82k8h/gEtqyuoZGv6+Ugawg=="],
|
||||||
|
|
||||||
|
"@next/swc-darwin-x64": ["@next/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-AKC/qVjUGUQDSPI6gESTx0xOnOPQ5gttogNS3o6bA83yiaSZJek0Am5yXy82F1KcZCx3DdOwdGPZpQCluonuxg=="],
|
||||||
|
|
||||||
|
"@next/swc-linux-arm64-gnu": ["@next/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-POQ65+pnYOkZNdngWfMEt7r53bzWiKkVNbjpmCt1Zb3V6lxJNXSsjwRuTQ8P/kguxDC8LRkqaL3vvsFrce4dMQ=="],
|
||||||
|
|
||||||
|
"@next/swc-linux-arm64-musl": ["@next/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-3Wm0zGYVCs6qDFAiSSDL+Z+r46EdtCv/2l+UlIdMbAq9hPJBvGu/rZOeuvCaIUjbArkmXac8HnTyQPJFzFWA0Q=="],
|
||||||
|
|
||||||
|
"@next/swc-linux-x64-gnu": ["@next/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-lWAYAezFinaJiD5Gv8HDidtsZdT3CDaCeqoPoJjeB57OqzvMajpIhlZFce5sCAH6VuX4mdkxCRqecCJFwfm2nQ=="],
|
||||||
|
|
||||||
|
"@next/swc-linux-x64-musl": ["@next/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-fHaIpT7x4gA6VQbdEpYUXRGyge/YbRrkG6DXM60XiBqDM2g2NcrsQaIuj375egnGFkJow4RHacgBOEsHfGbiUw=="],
|
||||||
|
|
||||||
|
"@next/swc-win32-arm64-msvc": ["@next/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-MCrXxrTSE7jPN1NyXJr39E+aNFBrQZtO154LoCz7n99FuKqJDekgxipoodLNWdQP7/DZ5tKMc/efybx1l159hw=="],
|
||||||
|
|
||||||
|
"@next/swc-win32-x64-msvc": ["@next/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-JSVlm9MDhmTXw/sO2PE/MRj+G6XOSMZB+BcZ0a7d6KwVFZVpkHcb2okyoYFBaco6LeiL53BBklRlOrDDbOeE5w=="],
|
||||||
|
|
||||||
"@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
"@nodelib/fs.scandir": ["@nodelib/[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
|
|
||||||
"@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
"@nodelib/fs.stat": ["@nodelib/[email protected]", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||||
@@ -294,6 +395,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-separator": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
"@radix-ui/react-separator": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slider": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
|
||||||
|
|
||||||
"@radix-ui/react-slot": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
"@radix-ui/react-slot": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||||
|
|
||||||
"@radix-ui/react-tabs": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
"@radix-ui/react-tabs": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||||
@@ -374,6 +477,8 @@
|
|||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="],
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="],
|
||||||
|
|
||||||
|
"@swc/helpers": ["@swc/[email protected]", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
"@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide": ["@tailwindcss/[email protected]", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
"@tailwindcss/oxide": ["@tailwindcss/[email protected]", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||||
@@ -438,8 +543,12 @@
|
|||||||
|
|
||||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="],
|
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="],
|
||||||
|
|
||||||
|
"@tauri-apps/plugin-process": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
|
||||||
|
|
||||||
"@tauri-apps/plugin-shell": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA=="],
|
"@tauri-apps/plugin-shell": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA=="],
|
||||||
|
|
||||||
|
"@tauri-apps/plugin-updater": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg=="],
|
||||||
|
|
||||||
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||||
|
|
||||||
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||||
@@ -480,6 +589,8 @@
|
|||||||
|
|
||||||
"@voicebox/app": ["@voicebox/app@workspace:app"],
|
"@voicebox/app": ["@voicebox/app@workspace:app"],
|
||||||
|
|
||||||
|
"@voicebox/landing": ["@voicebox/landing@workspace:landing"],
|
||||||
|
|
||||||
"@voicebox/tauri": ["@voicebox/tauri@workspace:tauri"],
|
"@voicebox/tauri": ["@voicebox/tauri@workspace:tauri"],
|
||||||
|
|
||||||
"@voicebox/web": ["@voicebox/web@workspace:web"],
|
"@voicebox/web": ["@voicebox/web@workspace:web"],
|
||||||
@@ -494,16 +605,26 @@
|
|||||||
|
|
||||||
"ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
|
"any-promise": ["[email protected]", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||||
|
|
||||||
|
"anymatch": ["[email protected]", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||||
|
|
||||||
|
"arg": ["[email protected]", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||||
|
|
||||||
"argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
"aria-hidden": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
"aria-hidden": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||||
|
|
||||||
"array-union": ["[email protected]", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
|
"array-union": ["[email protected]", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
|
||||||
|
|
||||||
|
"autoprefixer": ["[email protected]", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
|
||||||
|
|
||||||
"balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="],
|
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="],
|
||||||
|
|
||||||
|
"binary-extensions": ["[email protected]", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|
||||||
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
"braces": ["[email protected]", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
@@ -512,24 +633,34 @@
|
|||||||
|
|
||||||
"callsites": ["[email protected]", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
"callsites": ["[email protected]", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
|
"camelcase-css": ["[email protected]", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||||
|
|
||||||
"caniuse-lite": ["[email protected]", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
|
"caniuse-lite": ["[email protected]", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
|
||||||
|
|
||||||
"chalk": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"chalk": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
|
"chokidar": ["[email protected]", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||||
|
|
||||||
"class-variance-authority": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
"class-variance-authority": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
|
"client-only": ["[email protected]", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||||
|
|
||||||
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
"color-convert": ["[email protected]", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"color-convert": ["[email protected]", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
"color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"commander": ["[email protected]", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||||
|
|
||||||
"concat-map": ["[email protected]", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
"concat-map": ["[email protected]", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||||
|
|
||||||
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
|
"cssesc": ["[email protected]", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||||
|
|
||||||
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"date-fns": ["[email protected]", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
|
"date-fns": ["[email protected]", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
|
||||||
@@ -542,8 +673,12 @@
|
|||||||
|
|
||||||
"detect-node-es": ["[email protected]", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
"detect-node-es": ["[email protected]", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
|
|
||||||
|
"didyoumean": ["[email protected]", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||||
|
|
||||||
"dir-glob": ["[email protected]", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
|
"dir-glob": ["[email protected]", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
|
||||||
|
|
||||||
|
"dlv": ["[email protected]", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||||
|
|
||||||
"doctrine": ["[email protected]", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="],
|
"doctrine": ["[email protected]", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="],
|
||||||
|
|
||||||
"electron-to-chromium": ["[email protected]", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="],
|
"electron-to-chromium": ["[email protected]", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="],
|
||||||
@@ -586,6 +721,8 @@
|
|||||||
|
|
||||||
"fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
"fastq": ["[email protected]", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||||
|
|
||||||
|
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="],
|
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="],
|
||||||
|
|
||||||
"fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
"fill-range": ["[email protected]", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
@@ -596,10 +733,16 @@
|
|||||||
|
|
||||||
"flatted": ["[email protected]", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
"flatted": ["[email protected]", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||||
|
|
||||||
|
"fraction.js": ["[email protected]", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||||
|
|
||||||
|
"framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.29.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg=="],
|
||||||
|
|
||||||
"fs.realpath": ["[email protected]", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
"fs.realpath": ["[email protected]", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||||
|
|
||||||
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
"get-nonce": ["[email protected]", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
"get-nonce": ["[email protected]", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||||
@@ -618,6 +761,8 @@
|
|||||||
|
|
||||||
"has-flag": ["[email protected]", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
"has-flag": ["[email protected]", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
|
|
||||||
|
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
"ignore": ["[email protected]", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["[email protected]", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
"import-fresh": ["[email protected]", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
"import-fresh": ["[email protected]", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||||
@@ -628,6 +773,10 @@
|
|||||||
|
|
||||||
"inherits": ["[email protected]", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
"inherits": ["[email protected]", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
|
"is-binary-path": ["[email protected]", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||||
|
|
||||||
|
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||||
|
|
||||||
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
@@ -638,7 +787,7 @@
|
|||||||
|
|
||||||
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||||
|
|
||||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
@@ -682,6 +831,10 @@
|
|||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||||
|
|
||||||
|
"lilconfig": ["[email protected]", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||||
|
|
||||||
|
"lines-and-columns": ["[email protected]", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||||
@@ -700,14 +853,28 @@
|
|||||||
|
|
||||||
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||||
|
|
||||||
|
"motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.27.2" } }, "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA=="],
|
||||||
|
|
||||||
|
"motion-utils": ["[email protected]", "", {}, "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q=="],
|
||||||
|
|
||||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"mz": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||||
|
|
||||||
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
"natural-compare": ["[email protected]", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"natural-compare": ["[email protected]", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
|
"next": ["[email protected]", "", { "dependencies": { "@next/env": "16.1.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.4", "@next/swc-darwin-x64": "16.1.4", "@next/swc-linux-arm64-gnu": "16.1.4", "@next/swc-linux-arm64-musl": "16.1.4", "@next/swc-linux-x64-gnu": "16.1.4", "@next/swc-linux-x64-musl": "16.1.4", "@next/swc-win32-arm64-msvc": "16.1.4", "@next/swc-win32-x64-msvc": "16.1.4", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-gKSecROqisnV7Buen5BfjmXAm7Xlpx9o2ueVQRo5DxQcjC8d330dOM1xiGWc2k3Dcnz0In3VybyRPOsudwgiqQ=="],
|
||||||
|
|
||||||
"node-releases": ["[email protected]", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
"node-releases": ["[email protected]", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||||
|
|
||||||
|
"normalize-path": ["[email protected]", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||||
|
|
||||||
|
"object-assign": ["[email protected]", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
|
"object-hash": ["[email protected]", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||||
|
|
||||||
"once": ["[email protected]", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
"once": ["[email protected]", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
"optionator": ["[email protected]", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
"optionator": ["[email protected]", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||||
@@ -724,14 +891,32 @@
|
|||||||
|
|
||||||
"path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
|
"path-parse": ["[email protected]", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
"path-type": ["[email protected]", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
|
"path-type": ["[email protected]", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
|
||||||
|
|
||||||
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
|
"pify": ["[email protected]", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
|
||||||
|
|
||||||
|
"pirates": ["[email protected]", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||||
|
|
||||||
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"postcss-import": ["[email protected]", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||||
|
|
||||||
|
"postcss-js": ["[email protected]", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
|
||||||
|
|
||||||
|
"postcss-load-config": ["[email protected]", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
|
||||||
|
|
||||||
|
"postcss-nested": ["[email protected]", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||||
|
|
||||||
|
"postcss-selector-parser": ["[email protected]", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||||
|
|
||||||
|
"postcss-value-parser": ["[email protected]", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||||
|
|
||||||
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|
||||||
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
@@ -752,6 +937,12 @@
|
|||||||
|
|
||||||
"react-style-singleton": ["[email protected]", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
"react-style-singleton": ["[email protected]", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
|
"read-cache": ["[email protected]", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||||
|
|
||||||
|
"readdirp": ["[email protected]", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||||
|
|
||||||
|
"resolve": ["[email protected]", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||||
|
|
||||||
"resolve-from": ["[email protected]", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
"resolve-from": ["[email protected]", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
|
|
||||||
"reusify": ["[email protected]", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
"reusify": ["[email protected]", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||||
@@ -766,6 +957,8 @@
|
|||||||
|
|
||||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"sharp": ["[email protected]", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||||
|
|
||||||
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
|
|
||||||
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
@@ -778,8 +971,14 @@
|
|||||||
|
|
||||||
"strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
"strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||||
|
|
||||||
|
"styled-jsx": ["[email protected]", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||||
|
|
||||||
|
"sucrase": ["[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||||
|
|
||||||
"supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
"supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
|
|
||||||
|
"supports-preserve-symlinks-flag": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
"tailwind-merge": ["[email protected]", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
|
"tailwind-merge": ["[email protected]", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
|
||||||
|
|
||||||
"tailwindcss": ["[email protected]", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
"tailwindcss": ["[email protected]", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
@@ -790,10 +989,18 @@
|
|||||||
|
|
||||||
"text-table": ["[email protected]", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="],
|
"text-table": ["[email protected]", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="],
|
||||||
|
|
||||||
|
"thenify": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||||
|
|
||||||
|
"thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["[email protected]", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
"ts-api-utils": ["[email protected]", "", { "peerDependencies": { "typescript": ">=4.2.0" } }, "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw=="],
|
"ts-api-utils": ["[email protected]", "", { "peerDependencies": { "typescript": ">=4.2.0" } }, "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw=="],
|
||||||
|
|
||||||
|
"ts-interface-checker": ["[email protected]", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||||
|
|
||||||
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||||
@@ -814,6 +1021,8 @@
|
|||||||
|
|
||||||
"use-sync-external-store": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
"use-sync-external-store": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||||
|
|
||||||
|
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||||
|
|
||||||
"wavesurfer.js": ["[email protected]", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="],
|
"wavesurfer.js": ["[email protected]", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="],
|
||||||
@@ -858,6 +1067,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node/jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
@@ -874,8 +1085,22 @@
|
|||||||
|
|
||||||
"@typescript-eslint/typescript-estree/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
"@typescript-eslint/typescript-estree/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||||
|
|
||||||
|
"@voicebox/landing/lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-dTmYX1H4IXsRfVcj/KUxworV6814ApTl7iXaS21AimK2RUEl4j4AfOmqD3VR8phe5V91m4vEJ8tCK4uT1jE5nA=="],
|
||||||
|
|
||||||
|
"@voicebox/landing/tailwind-merge": ["[email protected]", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||||
|
|
||||||
|
"@voicebox/landing/tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
||||||
|
|
||||||
|
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"next/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||||
|
|
||||||
|
"sharp/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||||
|
|
||||||
|
"tinyglobby/picomatch": ["[email protected]", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# Tauri v2 Autoupdater Setup
|
||||||
|
|
||||||
|
The autoupdater has been configured for this project. Follow these steps to complete the setup.
|
||||||
|
|
||||||
|
## 1. Generate Signing Keys
|
||||||
|
|
||||||
|
Run this command to generate your signing keypair:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tauri && bun tauri signer generate -w ~/.tauri/voicebox.key
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
- **Private key**: `~/.tauri/voicebox.key` (keep this secret!)
|
||||||
|
- **Public key**: `~/.tauri/voicebox.key.pub`
|
||||||
|
|
||||||
|
## 2. Update Configuration
|
||||||
|
|
||||||
|
Copy the content from `~/.tauri/voicebox.key.pub` and replace the placeholder in `tauri/src-tauri/tauri.conf.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "PASTE_PUBLIC_KEY_CONTENT_HERE",
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/YOUR_USERNAME/voicebox/releases/latest/download/latest.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the endpoint URL with your actual GitHub username/organization.
|
||||||
|
|
||||||
|
## 3. Building with Signatures
|
||||||
|
|
||||||
|
When building releases, set these environment variables:
|
||||||
|
|
||||||
|
**macOS/Linux:**
|
||||||
|
```bash
|
||||||
|
export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/voicebox.key)"
|
||||||
|
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows PowerShell:**
|
||||||
|
```powershell
|
||||||
|
$env:TAURI_SIGNING_PRIVATE_KEY = Get-Content ~/.tauri/voicebox.key -Raw
|
||||||
|
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = ""
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. GitHub Release Setup
|
||||||
|
|
||||||
|
When you create a GitHub release, the build process will generate:
|
||||||
|
- Installers for each platform
|
||||||
|
- `.sig` signature files
|
||||||
|
- `latest.json` update manifest
|
||||||
|
|
||||||
|
### Manual Release Process
|
||||||
|
|
||||||
|
1. Build the app with signing keys set
|
||||||
|
2. Create a new GitHub release
|
||||||
|
3. Upload all files from `tauri/src-tauri/target/release/bundle/`
|
||||||
|
4. Create `latest.json` in your release assets:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"notes": "Bug fixes and improvements",
|
||||||
|
"pub_date": "2026-01-25T12:00:00Z",
|
||||||
|
"platforms": {
|
||||||
|
"darwin-aarch64": {
|
||||||
|
"signature": "CONTENT_FROM_.app.tar.gz.sig",
|
||||||
|
"url": "https://github.com/YOUR_USERNAME/voicebox/releases/download/v0.2.0/voicebox_0.2.0_aarch64.dmg"
|
||||||
|
},
|
||||||
|
"darwin-x86_64": {
|
||||||
|
"signature": "CONTENT_FROM_.app.tar.gz.sig",
|
||||||
|
"url": "https://github.com/YOUR_USERNAME/voicebox/releases/download/v0.2.0/voicebox_0.2.0_x64.dmg"
|
||||||
|
},
|
||||||
|
"linux-x86_64": {
|
||||||
|
"signature": "CONTENT_FROM_.AppImage.sig",
|
||||||
|
"url": "https://github.com/YOUR_USERNAME/voicebox/releases/download/v0.2.0/voicebox_0.2.0_amd64.AppImage"
|
||||||
|
},
|
||||||
|
"windows-x86_64": {
|
||||||
|
"signature": "CONTENT_FROM_.msi.sig",
|
||||||
|
"url": "https://github.com/YOUR_USERNAME/voicebox/releases/download/v0.2.0/voicebox_0.2.0_x64_en-US.msi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automated GitHub Actions (Recommended)
|
||||||
|
|
||||||
|
Create `.github/workflows/release.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
platform: [macos-latest, ubuntu-22.04, windows-latest]
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v1
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install dependencies (Ubuntu)
|
||||||
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Upload Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: tauri/src-tauri/target/release/bundle/**/*
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add your private key to GitHub secrets:
|
||||||
|
- Go to Settings → Secrets and variables → Actions
|
||||||
|
- Add `TAURI_SIGNING_PRIVATE_KEY` with the content of `~/.tauri/voicebox.key`
|
||||||
|
- Add `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` (empty string if no password)
|
||||||
|
|
||||||
|
## 5. Frontend Integration (Already Completed)
|
||||||
|
|
||||||
|
The frontend integration is already complete in this project with the following components:
|
||||||
|
|
||||||
|
### Automatic Update Notifications
|
||||||
|
- `app/src/components/UpdateNotification.tsx` - Shows a banner when updates are available
|
||||||
|
- Automatically checks for updates on app startup
|
||||||
|
- Displays download/install progress
|
||||||
|
- Only shows in Tauri desktop builds
|
||||||
|
|
||||||
|
### Manual Update Check
|
||||||
|
- `app/src/components/ServerSettings/UpdateStatus.tsx` - Settings panel for updates
|
||||||
|
- Allows manual update checks via "Check for Updates" button
|
||||||
|
- Shows current version and update status
|
||||||
|
- Located in the Settings tab (only visible in Tauri builds)
|
||||||
|
|
||||||
|
### Update Hook
|
||||||
|
- `app/src/hooks/useAutoUpdater.ts` - React hook for update functionality
|
||||||
|
- Handles update checking, downloading, and installation
|
||||||
|
- Includes Tauri context detection (won't run in web builds)
|
||||||
|
|
||||||
|
The components are already integrated into the main App layout.
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Never commit your private key to version control
|
||||||
|
- Store private keys securely (use GitHub secrets for CI/CD)
|
||||||
|
- The public key in `tauri.conf.json` is safe to commit
|
||||||
|
- Updates are cryptographically verified before installation
|
||||||
|
- HTTP endpoints are blocked by default (HTTPS only)
|
||||||
|
|
||||||
|
## Testing Updates
|
||||||
|
|
||||||
|
1. Build version 0.1.0 and install it
|
||||||
|
2. Update version in `tauri.conf.json` to 0.2.0
|
||||||
|
3. Build version 0.2.0 with signatures
|
||||||
|
4. Create a local server or GitHub release with `latest.json`
|
||||||
|
5. Run version 0.1.0 and trigger update check
|
||||||
|
6. Verify update downloads and installs correctly
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**"Invalid signature" error:**
|
||||||
|
- Verify public key matches the private key used to sign
|
||||||
|
- Ensure signature files (.sig) are uploaded correctly
|
||||||
|
|
||||||
|
**"No update available" when one exists:**
|
||||||
|
- Check endpoint URL is correct
|
||||||
|
- Verify `latest.json` format matches specification
|
||||||
|
- Ensure version in latest.json is higher than current version
|
||||||
|
|
||||||
|
**Build fails with signing:**
|
||||||
|
- Confirm environment variables are set correctly
|
||||||
|
- Check private key file exists and is readable
|
||||||
|
- Verify private key format (should start with `dW50cnVzdGVkIGNvbW1lbnQ6`)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Autoupdater Quick Start
|
||||||
|
|
||||||
|
The Tauri v2 autoupdater has been fully configured and integrated. Follow these steps to activate it.
|
||||||
|
|
||||||
|
## What's Already Done
|
||||||
|
|
||||||
|
✅ Rust plugin installed and initialized
|
||||||
|
✅ Tauri configuration set up with updater settings
|
||||||
|
✅ Permissions granted for update operations
|
||||||
|
✅ GitHub Actions workflow updated with signing support
|
||||||
|
✅ Frontend components created and integrated
|
||||||
|
✅ Update notifications on app startup
|
||||||
|
✅ Manual update check in Settings tab
|
||||||
|
|
||||||
|
## Required Steps (5 minutes)
|
||||||
|
|
||||||
|
### 1. Generate Signing Keys
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run generate:keys
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
- Private key: `~/.tauri/voicebox.key` (keep secret!)
|
||||||
|
- Public key: `~/.tauri/voicebox.key.pub` (safe to share)
|
||||||
|
|
||||||
|
### 2. Update Tauri Config
|
||||||
|
|
||||||
|
Open `tauri/src-tauri/tauri.conf.json` and:
|
||||||
|
|
||||||
|
1. Replace `"REPLACE_WITH_YOUR_PUBLIC_KEY"` with the content from `~/.tauri/voicebox.key.pub`
|
||||||
|
2. Update the endpoint URL with your GitHub username:
|
||||||
|
```json
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/YOUR_USERNAME/voicebox/releases/latest/download/latest.json"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add GitHub Secrets
|
||||||
|
|
||||||
|
Go to your repo Settings → Secrets and variables → Actions:
|
||||||
|
|
||||||
|
1. Add `TAURI_SIGNING_PRIVATE_KEY`:
|
||||||
|
```bash
|
||||||
|
cat ~/.tauri/voicebox.key
|
||||||
|
```
|
||||||
|
Copy the entire output and paste as the secret value
|
||||||
|
|
||||||
|
2. Add `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`:
|
||||||
|
Leave empty (or add your password if you set one)
|
||||||
|
|
||||||
|
### 4. Test the Setup
|
||||||
|
|
||||||
|
To test locally before creating a release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build:release
|
||||||
|
```
|
||||||
|
|
||||||
|
This will verify your keys are set up correctly.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
1. App checks for updates on startup (only in Tauri builds)
|
||||||
|
2. If an update is available, a banner appears at the top
|
||||||
|
3. Users can click "Install Now" to download and install
|
||||||
|
4. App restarts automatically after installation
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
1. Create a new git tag: `git tag v0.2.0 && git push --tags`
|
||||||
|
2. GitHub Actions builds signed releases for all platforms
|
||||||
|
3. Uploads installers and generates `latest.json` manifest
|
||||||
|
4. Users running older versions will be notified automatically
|
||||||
|
|
||||||
|
## UI Components
|
||||||
|
|
||||||
|
### Update Notification Banner
|
||||||
|
- Shows at top of app when update is available
|
||||||
|
- Appears automatically on startup
|
||||||
|
- Displays download/install progress
|
||||||
|
|
||||||
|
### Settings Panel
|
||||||
|
- Located in Settings tab
|
||||||
|
- Shows current version
|
||||||
|
- Manual "Check for Updates" button
|
||||||
|
- Update status and progress
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**"Public key not configured"**
|
||||||
|
- Make sure you copied the entire content from `voicebox.key.pub`
|
||||||
|
- The key should start with `dW50cnVzdGVkIGNvbW1lbnQ6`
|
||||||
|
|
||||||
|
**"Failed to check for updates"**
|
||||||
|
- Endpoint URL might be incorrect
|
||||||
|
- No releases published yet (expected for first setup)
|
||||||
|
|
||||||
|
**Build fails with signing error**
|
||||||
|
- Check that GitHub secrets are set correctly
|
||||||
|
- Verify private key file exists at `~/.tauri/voicebox.key`
|
||||||
|
|
||||||
|
## Next Release Workflow
|
||||||
|
|
||||||
|
1. Update version in `tauri/src-tauri/tauri.conf.json`
|
||||||
|
2. Commit changes
|
||||||
|
3. Create and push tag: `git tag v0.2.0 && git push --tags`
|
||||||
|
4. GitHub Actions will automatically build and create a draft release
|
||||||
|
5. Review the release and publish it
|
||||||
|
6. Users will be notified of the update
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- Full documentation: `docs/AUTOUPDATER.md`
|
||||||
|
- Build script: `scripts/prepare-release.sh`
|
||||||
|
- GitHub workflow: `.github/workflows/release.yml`
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Icon Update Workflow for voicebox
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Xcode Command Line Tools installed (`xcode-select --install`)
|
||||||
|
- Your icon designed in Icon Composer and saved as `voicebox.icon` in the project root
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
```
|
||||||
|
voicebox/
|
||||||
|
├── voicebox.icon/ # macOS 26 Liquid Glass icon bundle
|
||||||
|
│ ├── icon.json
|
||||||
|
│ └── Assets/
|
||||||
|
│ └── Voicebox.png # Your source image
|
||||||
|
├── tauri/
|
||||||
|
│ ├── assets/
|
||||||
|
│ │ └── voicebox_exports/ # Your 1024x1024 exports
|
||||||
|
│ └── src-tauri/
|
||||||
|
│ ├── gen/ # Auto-generated at build time
|
||||||
|
│ │ ├── Assets.car # Liquid Glass assets
|
||||||
|
│ │ ├── voicebox.icns # Generated fallback icon
|
||||||
|
│ │ └── partial.plist
|
||||||
|
│ └── icons/ # Tauri fallback icons (all platforms)
|
||||||
|
│ ├── icon.icns
|
||||||
|
│ ├── icon.ico
|
||||||
|
│ ├── 32x32.png
|
||||||
|
│ ├── 128x128.png
|
||||||
|
│ └── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: Update the Liquid Glass Icon
|
||||||
|
|
||||||
|
Edit `voicebox.icon/` in Icon Composer (or manually update `icon.json` and `Assets/`).
|
||||||
|
|
||||||
|
The build script (`build.rs`) automatically compiles this during `cargo build`.
|
||||||
|
|
||||||
|
## Step 2: Regenerate Fallback Icons
|
||||||
|
|
||||||
|
After updating the `.icon` bundle, regenerate the fallback icons:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tauri/src-tauri
|
||||||
|
|
||||||
|
# Trigger rebuild to generate new icns
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Copy the generated icns to icons folder
|
||||||
|
cp gen/voicebox.icns icons/icon.icns
|
||||||
|
|
||||||
|
# Generate PNGs from the icns
|
||||||
|
sips -s format png -z 32 32 gen/voicebox.icns --out icons/32x32.png
|
||||||
|
sips -s format png -z 64 64 gen/voicebox.icns --out icons/64x64.png
|
||||||
|
sips -s format png -z 128 128 gen/voicebox.icns --out icons/128x128.png
|
||||||
|
sips -s format png -z 256 256 gen/voicebox.icns --out icons/128x128@2x.png
|
||||||
|
sips -s format png -z 512 512 gen/voicebox.icns --out icons/icon.png
|
||||||
|
|
||||||
|
# Windows Square logos
|
||||||
|
for size in 30 44 71 89 107 142 150 284 310; do
|
||||||
|
sips -s format png -z $size $size gen/voicebox.icns --out "icons/Square${size}x${size}Logo.png"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Rebuild the App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tauri && bun run tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Clear Icon Cache (if icons don't update)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo rm -rf /Library/Caches/com.apple.iconservices.store
|
||||||
|
sudo killall Finder
|
||||||
|
sudo killall Dock
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `voicebox.icon/` | Source for macOS 26 Liquid Glass icon |
|
||||||
|
| `build.rs` | Compiles `.icon` → `Assets.car` + `voicebox.icns` at build time |
|
||||||
|
| `gen/Assets.car` | Liquid Glass assets (macOS 26+) |
|
||||||
|
| `gen/voicebox.icns` | Auto-generated fallback icon |
|
||||||
|
| `icons/icon.icns` | Tauri's fallback for older macOS |
|
||||||
|
| `icons/*.png` | Tauri's fallback for other platforms |
|
||||||
|
| `Info.plist` | Points to `voicebox` as icon name |
|
||||||
|
| `tauri.conf.json` | Bundles `gen/*` to Resources root |
|
||||||
|
|
||||||
|
## Key Config Files
|
||||||
|
|
||||||
|
### `build.rs` — Compiles the icon
|
||||||
|
|
||||||
|
```rust
|
||||||
|
xcrun actool --app-icon voicebox ... voicebox.icon
|
||||||
|
```
|
||||||
|
|
||||||
|
### `tauri.conf.json` — Bundles generated assets to Resources root
|
||||||
|
|
||||||
|
```json
|
||||||
|
"resources": {
|
||||||
|
"gen/Assets.car": "./",
|
||||||
|
"gen/voicebox.icns": "./",
|
||||||
|
"gen/partial.plist": "./"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Info.plist` — Tells macOS which icon to use
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>voicebox</string>
|
||||||
|
<key>CFBundleIconName</key>
|
||||||
|
<string>voicebox</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference Script
|
||||||
|
|
||||||
|
Save this as `scripts/update-icons.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../tauri/src-tauri"
|
||||||
|
|
||||||
|
echo "Building to generate new icons..."
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
echo "Copying icns to icons folder..."
|
||||||
|
cp gen/voicebox.icns icons/icon.icns
|
||||||
|
|
||||||
|
echo "Generating PNG icons..."
|
||||||
|
sips -s format png -z 32 32 gen/voicebox.icns --out icons/32x32.png
|
||||||
|
sips -s format png -z 64 64 gen/voicebox.icns --out icons/64x64.png
|
||||||
|
sips -s format png -z 128 128 gen/voicebox.icns --out icons/128x128.png
|
||||||
|
sips -s format png -z 256 256 gen/voicebox.icns --out icons/128x128@2x.png
|
||||||
|
sips -s format png -z 512 512 gen/voicebox.icns --out icons/icon.png
|
||||||
|
|
||||||
|
echo "Generating Windows Square logos..."
|
||||||
|
for size in 30 44 71 89 107 142 150 284 310; do
|
||||||
|
sips -s format png -z $size $size gen/voicebox.icns --out "icons/Square${size}x${size}Logo.png"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Done! Icons updated."
|
||||||
|
echo "Run 'bun run tauri build' to rebuild the app."
|
||||||
|
```
|
||||||
|
|
||||||
|
Make it executable: `chmod +x scripts/update-icons.sh`
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Voicebox Landing Page
|
||||||
|
|
||||||
|
Landing page for voicebox.sh - a modern Next.js 16 application.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Next.js 16** with App Router
|
||||||
|
- **Bun** for package management
|
||||||
|
- **Tailwind CSS** with shadcn/ui components
|
||||||
|
- **TypeScript** with strict mode
|
||||||
|
- **Railway** deployment ready
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Bun installed ([bun.sh](https://bun.sh))
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd landing
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to view the landing page.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Update Download Links
|
||||||
|
|
||||||
|
Edit `src/lib/constants.ts` to update:
|
||||||
|
- `LATEST_VERSION` - Current release version
|
||||||
|
- `DOWNLOAD_LINKS` - GitHub release download URLs
|
||||||
|
- `GITHUB_REPO` - Repository URL
|
||||||
|
|
||||||
|
### Update GitHub Username
|
||||||
|
|
||||||
|
Replace `USERNAME` in `src/lib/constants.ts` with your actual GitHub username.
|
||||||
|
|
||||||
|
## Deployment to Railway
|
||||||
|
|
||||||
|
1. Connect your GitHub repository to Railway
|
||||||
|
2. Railway will auto-detect `nixpacks.toml`
|
||||||
|
3. Set root directory to `landing/`
|
||||||
|
4. Railway will automatically:
|
||||||
|
- Install dependencies with `bun install`
|
||||||
|
- Build with `bun run build`
|
||||||
|
- Start with `bun run start`
|
||||||
|
5. Configure custom domain `voicebox.sh` in Railway settings
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
landing/
|
||||||
|
├── src/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── layout.tsx # Root layout with metadata
|
||||||
|
│ │ ├── page.tsx # Landing page
|
||||||
|
│ │ └── globals.css # Global styles
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── Header.tsx # Top navigation
|
||||||
|
│ │ ├── Footer.tsx # Footer
|
||||||
|
│ │ ├── DownloadSection.tsx # Download buttons
|
||||||
|
│ │ └── ui/ # shadcn/ui components
|
||||||
|
│ └── lib/
|
||||||
|
│ ├── utils.ts # Utility functions
|
||||||
|
│ └── constants.ts # App constants
|
||||||
|
├── public/
|
||||||
|
│ └── voicebox-logo.png # Logo asset
|
||||||
|
└── nixpacks.toml # Railway deployment config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Responsive design (mobile-first)
|
||||||
|
- Dark mode by default
|
||||||
|
- SEO optimized metadata
|
||||||
|
- Download links for Mac, Windows, Linux
|
||||||
|
- Feature showcase
|
||||||
|
- Platform highlights
|
||||||
|
- GitHub integration
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
output: 'standalone',
|
||||||
|
images: {
|
||||||
|
unoptimized: false,
|
||||||
|
formats: ['image/avif', 'image/webp'],
|
||||||
|
},
|
||||||
|
turbopack: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[phases.setup]
|
||||||
|
nixPkgs = ["nodejs_20", "bun"]
|
||||||
|
|
||||||
|
[phases.install]
|
||||||
|
cmds = ["bun install"]
|
||||||
|
|
||||||
|
[phases.build]
|
||||||
|
cmds = ["bun run build"]
|
||||||
|
|
||||||
|
[start]
|
||||||
|
cmd = "bun run start"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@voicebox/landing",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Landing page for voicebox.sh",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun --bun next dev --turbo",
|
||||||
|
"build": "bun --bun next build",
|
||||||
|
"start": "bun --bun next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.316.0",
|
||||||
|
"next": "^16.1.3",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.5",
|
||||||
|
"@types/react": "^18.2.48",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 819 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
@@ -0,0 +1,69 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 0 0% 0%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 0%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 0%;
|
||||||
|
--primary: 0 0% 0%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 0 0% 96%;
|
||||||
|
--secondary-foreground: 0 0% 0%;
|
||||||
|
--muted: 0 0% 96%;
|
||||||
|
--muted-foreground: 0 0% 45%;
|
||||||
|
--accent: 0 0% 96%;
|
||||||
|
--accent-foreground: 0 0% 0%;
|
||||||
|
--destructive: 0 0% 0%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 0 0% 90%;
|
||||||
|
--input: 0 0% 90%;
|
||||||
|
--ring: 0 0% 0%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 0 0% 3%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 0 0% 8% / 0.6;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 0 0% 8% / 0.8;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 0 0% 8%;
|
||||||
|
--secondary: 0 0% 12% / 0.5;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 0 0% 12% / 0.4;
|
||||||
|
--muted-foreground: 0 0% 65%;
|
||||||
|
--accent: 0 0% 15% / 0.5;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62% 50%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 15% / 0.5;
|
||||||
|
--input: 0 0% 15% / 0.5;
|
||||||
|
--ring: 0 0% 98% / 0.2;
|
||||||
|
--radius: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground antialiased;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(at 0% 0%, rgba(255, 255, 255, 0.03) 0px, transparent 50%),
|
||||||
|
radial-gradient(at 100% 100%, rgba(255, 255, 255, 0.02) 0px, transparent 50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
import './globals.css';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { Footer } from '@/components/Footer';
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'voicebox - Professional Voice Cloning Desktop App',
|
||||||
|
description:
|
||||||
|
'Near-perfect voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
|
||||||
|
keywords: ['voice cloning', 'TTS', 'Qwen3', 'desktop app', 'AI voice'],
|
||||||
|
openGraph: {
|
||||||
|
title: 'voicebox',
|
||||||
|
description: 'Professional voice cloning with Qwen3-TTS',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://voicebox.sh',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning className="dark">
|
||||||
|
<body className={inter.variable}>
|
||||||
|
<div className="relative min-h-screen bg-background font-sans flex flex-col">
|
||||||
|
<Header />
|
||||||
|
<main className="container mx-auto px-4 sm:px-6 md:px-4 flex-1 py-4 sm:py-6 md:py-0">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Cloud, Code, Cpu, Github, Shield, Zap } from 'lucide-react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { AppleIcon, LinuxIcon, WindowsIcon } from '@/components/PlatformIcons';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Hero } from '@/components/ui/hero';
|
||||||
|
import { Section, SectionTitle } from '@/components/ui/section';
|
||||||
|
import { DOWNLOAD_LINKS, GITHUB_REPO } from '@/lib/constants';
|
||||||
|
import { FeatureCard } from '../components/ui/feature-card';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
title: 'Near-Perfect Voice Cloning',
|
||||||
|
description:
|
||||||
|
"Powered by Alibaba's Qwen3-TTS model for exceptional voice quality and accuracy.",
|
||||||
|
icon: <Zap className="h-6 w-6" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Multi-Sample Support',
|
||||||
|
description:
|
||||||
|
'Combine multiple voice samples for higher quality and more natural-sounding results.',
|
||||||
|
icon: <Code className="h-6 w-6" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Smart Caching',
|
||||||
|
description: 'Instant re-generation with voice prompt caching. No need to reprocess samples.',
|
||||||
|
icon: <Zap className="h-6 w-6" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Local or Remote',
|
||||||
|
description:
|
||||||
|
'Run GPU inference locally or connect to a remote machine. One-click server setup.',
|
||||||
|
icon: <Cloud className="h-6 w-6" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Audio Transcription',
|
||||||
|
description:
|
||||||
|
'Powered by Whisper for accurate speech-to-text. Extract reference text from voice samples automatically.',
|
||||||
|
icon: <Shield className="h-6 w-6" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Cross-Platform',
|
||||||
|
description: 'Available for macOS, Windows, and Linux. No Python installation required.',
|
||||||
|
icon: <Cpu className="h-6 w-6" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-12 sm:space-y-16 md:space-y-20">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<Hero
|
||||||
|
title="voicebox"
|
||||||
|
description="Open source voice cloning powered by Qwen3-TTS. Create natural-sounding speech from text with near-perfect voice replication."
|
||||||
|
actions={
|
||||||
|
<div className="space-y-4 w-full lg:w-auto">
|
||||||
|
<div className="w-full max-w-2xl mb-6">
|
||||||
|
<Image
|
||||||
|
src="/App.webp"
|
||||||
|
alt="Voicebox Application Screenshot"
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
className="w-full h-auto rounded-lg border border-border shadow-lg"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold mb-1">Download</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Choose your platform</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-2xl">
|
||||||
|
<Button asChild size="lg" className="w-full px-0">
|
||||||
|
<a
|
||||||
|
href={DOWNLOAD_LINKS.macArm}
|
||||||
|
download
|
||||||
|
className="flex items-center w-full relative"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
|
||||||
|
<AppleIcon className="h-5 w-5" />
|
||||||
|
<div className="h-5 w-px bg-border" />
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-center px-4">macOS (ARM)</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="lg" className="w-full px-0">
|
||||||
|
<a
|
||||||
|
href={DOWNLOAD_LINKS.macIntel}
|
||||||
|
download
|
||||||
|
className="flex items-center w-full relative"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
|
||||||
|
<AppleIcon className="h-5 w-5" />
|
||||||
|
<div className="h-5 w-px bg-border" />
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-center px-4">macOS (Intel)</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="lg" className="w-full px-0">
|
||||||
|
<a
|
||||||
|
href={DOWNLOAD_LINKS.windows}
|
||||||
|
download
|
||||||
|
className="flex items-center w-full relative"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
|
||||||
|
<WindowsIcon className="h-5 w-5" />
|
||||||
|
<div className="h-5 w-px bg-border" />
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-center px-4">Windows</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="lg" className="w-full px-0">
|
||||||
|
<a
|
||||||
|
href={DOWNLOAD_LINKS.linux}
|
||||||
|
download
|
||||||
|
className="flex items-center w-full relative"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 pl-4">
|
||||||
|
<LinuxIcon className="h-5 w-5" />
|
||||||
|
<div className="h-5 w-px bg-border" />
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-center px-4">Linux</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="lg" asChild className="w-full">
|
||||||
|
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Github className="h-4 w-4 mr-2" />
|
||||||
|
View on GitHub
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<Section id="features">
|
||||||
|
<SectionTitle className="mb-4 text-center">Features</SectionTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mb-8 text-center max-w-2xl mx-auto">
|
||||||
|
Everything you need for professional voice cloning in a desktop app.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<FeatureCard
|
||||||
|
key={feature.title}
|
||||||
|
title={feature.title}
|
||||||
|
description={feature.description}
|
||||||
|
icon={feature.icon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Download, Laptop, Monitor, Terminal } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { DOWNLOAD_LINKS, LATEST_VERSION } from '@/lib/constants';
|
||||||
|
|
||||||
|
export function DownloadSection() {
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
platform: 'Mac',
|
||||||
|
icon: Laptop,
|
||||||
|
link: DOWNLOAD_LINKS.mac,
|
||||||
|
description: 'macOS (Intel + Apple Silicon)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'Windows',
|
||||||
|
icon: Monitor,
|
||||||
|
link: DOWNLOAD_LINKS.windows,
|
||||||
|
description: 'Windows x64',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: 'Linux',
|
||||||
|
icon: Terminal,
|
||||||
|
link: DOWNLOAD_LINKS.linux,
|
||||||
|
description: 'Linux AppImage',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">Latest Version</p>
|
||||||
|
<p className="text-2xl font-bold">{LATEST_VERSION}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
|
||||||
|
{downloads.map(({ platform, icon: Icon, link, description }) => (
|
||||||
|
<Card
|
||||||
|
key={platform}
|
||||||
|
className="hover:border-primary/20 hover:shadow-lg hover:shadow-primary/3 transition-all duration-200 hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col items-center text-center space-y-4">
|
||||||
|
<div className="p-3 rounded-xl bg-muted/50 backdrop-blur-sm border border-border">
|
||||||
|
<Icon className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-1">{platform}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild size="lg" className="w-full">
|
||||||
|
<a href={link} download>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { GITHUB_REPO } from '@/lib/constants';
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-border pt-8 sm:pt-12 pb-6 sm:pb-8 mt-12 sm:mt-16 md:mt-20">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 sm:gap-8 mb-6 sm:mb-8">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg mb-4">voicebox</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Professional voice cloning powered by Qwen3-TTS. Desktop app for Mac, Windows, and
|
||||||
|
Linux.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-3">Product</h4>
|
||||||
|
<ul className="space-y-2 text-muted-foreground text-sm">
|
||||||
|
<li>
|
||||||
|
<a href="#features" className="hover:text-foreground transition-colors">
|
||||||
|
Features
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#download" className="hover:text-foreground transition-colors">
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={GITHUB_REPO}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-3">Resources</h4>
|
||||||
|
<ul className="space-y-2 text-muted-foreground text-sm">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={GITHUB_REPO}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Source Code
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`${GITHUB_REPO}/releases`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Releases
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`${GITHUB_REPO}/issues`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Issues
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-8" />
|
||||||
|
<div className="text-center text-muted-foreground text-sm space-y-2">
|
||||||
|
<p>© 2026 voicebox. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Github } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { GITHUB_REPO } from '@/lib/constants';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
return (
|
||||||
|
<header className="border-b border-border bg-background/60 backdrop-blur-2xl sticky top-0 z-50 supports-[backdrop-filter]:bg-background/40">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 font-bold text-xl sm:text-2xl hover:opacity-80 transition-opacity tracking-tight"
|
||||||
|
>
|
||||||
|
voicebox
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
|
<Button variant="outline" size="sm" asChild className="hidden sm:flex">
|
||||||
|
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Github className="h-4 w-4 mr-2" />
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" asChild className="sm:hidden">
|
||||||
|
<a href={GITHUB_REPO} target="_blank" rel="noopener noreferrer" aria-label="GitHub">
|
||||||
|
<Github className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export function AppleIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowsIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v7.81l-6-1.15V13zm17 .25V22l-10-1.8v-7.15l10 .15z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinuxIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.26-.195.69-.133 1.001.054.27.112.553.077.784-.12.794-.3 1.593-.3 2.406 0 .599.18 1.193.3 1.791.12.599.3 1.193.3 1.792 0 .812.18 1.611.3 2.405.035.23-.023.514-.077.783-.062.312-.127.742.133 1.002a.424.424 0 00.11.135c-.123.805.01 1.657.287 2.489.589 1.771 1.831 3.47 2.716 4.521.75 1.067 0.974 1.928 1.05 3.02.065 1.491-1.056 5.965 3.17 6.298.165.013.325.021.48.021.155 0 .315-.008.48-.021 4.226-.333 3.105-4.807 3.17-6.298.076-1.092.3-1.953 1.05-3.02.885-1.051 2.127-2.75 2.716-4.521.278-.832.41-1.684.287-2.489a.424.424 0 00.11-.135c.26-.26.195-.69.133-1.001-.054-.27-.112-.553-.077-.784.12-.794.3-1.593.3-2.406 0-.599-.18-1.193-.3-1.791-.12-.599-.3-1.193-.3-1.792 0-.812-.18-1.611-.3-2.405-.035-.23.023-.514.077-.783.062-.312.127-.742-.133-1.002a.424.424 0 00-.11-.135c.123-.805-.01-1.657-.287-2.489-.589-1.771-1.831-3.47-2.716-4.521-.75-1.067-.974-1.928-1.05-3.02-.065-1.491 1.056-5.965-3.17-6.298C12.819.008 12.659 0 12.504 0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-medium ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'bg-primary/10 backdrop-blur-sm border border-border text-primary hover:bg-primary/11 hover:border-primary/15 shadow-lg shadow-black/20 active:scale-[0.99]',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-lg shadow-destructive/20 active:scale-[0.98]',
|
||||||
|
outline:
|
||||||
|
'border border-border bg-background/50 backdrop-blur-sm hover:bg-foreground/5 transition-all active:scale-[0.99]',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/70 backdrop-blur-sm active:scale-[0.99]',
|
||||||
|
ghost:
|
||||||
|
'hover:bg-accent/30 hover:text-accent-foreground backdrop-blur-sm active:scale-[0.99]',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-lg px-3',
|
||||||
|
lg: 'h-12 rounded-xl text-base px-8 font-semibold',
|
||||||
|
icon: 'h-10 w-10 rounded-xl',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl border border-border bg-card backdrop-blur-xl text-card-foreground shadow-lg shadow-black/20',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6 pb-4', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-4', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface FeatureCardProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeatureCard({ title, description, icon, className }: FeatureCardProps) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'text-center hover:border-primary/20 hover:shadow-lg hover:shadow-primary/3 transition-all duration-200 hover:-translate-y-0.5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
{icon && (
|
||||||
|
<div className="flex justify-center mb-3">
|
||||||
|
<div className="p-3 rounded-xl bg-muted/50 backdrop-blur-sm border border-border">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<CardTitle className="text-xl sm:text-2xl">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardDescription className="text-sm sm:text-base">{description}</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface HeroProps {
|
||||||
|
title: ReactNode;
|
||||||
|
description: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
showLogo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hero({ title, description, actions, className, showLogo = true }: HeroProps) {
|
||||||
|
return (
|
||||||
|
<section className={cn('relative py-12 sm:py-16 md:py-20 lg:py-24', className)}>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 items-center">
|
||||||
|
{/* Left side - Content */}
|
||||||
|
<div className="space-y-6 lg:pr-8">
|
||||||
|
{showLogo && (
|
||||||
|
<div className="flex lg:justify-start justify-center mb-6">
|
||||||
|
<Image
|
||||||
|
src="/voicebox-logo-2.png"
|
||||||
|
alt="Voicebox Logo"
|
||||||
|
width={1024}
|
||||||
|
height={1024}
|
||||||
|
className="w-32 sm:w-40 md:w-48 h-auto"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h1 className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-bold leading-tight text-left">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg sm:text-xl md:text-2xl text-foreground/70 max-w-xl text-left">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Actions */}
|
||||||
|
<div className="flex flex-col items-start lg:items-end gap-4">{actions}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface SectionProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Section({ children, className, id }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<section id={id} className={cn('space-y-6 sm:space-y-8', className)}>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionTitle({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<h2
|
||||||
|
className={cn(
|
||||||
|
'text-2xl sm:text-3xl md:text-4xl font-bold text-center md:text-left',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Download links for voicebox releases
|
||||||
|
// Update these when new releases are published
|
||||||
|
export const LATEST_VERSION = 'v0.1.0';
|
||||||
|
|
||||||
|
export const DOWNLOAD_LINKS = {
|
||||||
|
macArm:
|
||||||
|
'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_aarch64-apple-darwin.dmg',
|
||||||
|
macIntel:
|
||||||
|
'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_x86_64-apple-darwin.dmg',
|
||||||
|
windows:
|
||||||
|
'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_x86_64-pc-windows-msvc.msi',
|
||||||
|
linux:
|
||||||
|
'https://github.com/USERNAME/voicebox/releases/download/v0.1.0/voicebox_x86_64-unknown-linux-gnu.AppImage',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const GITHUB_REPO = 'https://github.com/USERNAME/voicebox';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('tailwindcss-animate')],
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
+8
-2
@@ -5,16 +5,22 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"app",
|
"app",
|
||||||
"tauri",
|
"tauri",
|
||||||
"web"
|
"web",
|
||||||
|
"landing"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cd tauri && bun run tauri dev",
|
"dev": "cd tauri && bun run tauri dev",
|
||||||
"dev:web": "cd web && bun run dev",
|
"dev:web": "cd web && bun run dev",
|
||||||
"dev:backend": "source backend/venv/bin/activate && uvicorn backend.main:app --reload --port 8000",
|
"dev:landing": "cd landing && bun run dev",
|
||||||
|
"dev:server": "source backend/venv/bin/activate && uvicorn backend.main:app --reload --port 8000",
|
||||||
"build": "cd tauri && bun run tauri build",
|
"build": "cd tauri && bun run tauri build",
|
||||||
"build:web": "cd web && bun run build",
|
"build:web": "cd web && bun run build",
|
||||||
|
"build:landing": "cd landing && bun run build",
|
||||||
|
"build:release": "./scripts/prepare-release.sh",
|
||||||
"generate:api": "./scripts/generate-api.sh",
|
"generate:api": "./scripts/generate-api.sh",
|
||||||
|
"generate:keys": "cd tauri && bun tauri signer generate -w ~/.tauri/voicebox.key",
|
||||||
"build:server": "./scripts/build-server.sh",
|
"build:server": "./scripts/build-server.sh",
|
||||||
|
"update:icons": "./scripts/update-icons.sh",
|
||||||
"lint": "biome lint .",
|
"lint": "biome lint .",
|
||||||
"lint:fix": "biome lint --write .",
|
"lint:fix": "biome lint --write .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user