mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 17:15:19 -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",
|
||||||
|
|||||||
+80
-44
@@ -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
|
serverStarting = false;
|
||||||
if (import.meta.env?.PROD) {
|
|
||||||
stopServer().catch((error) => {
|
|
||||||
console.error('Failed to stop server on cleanup:', error);
|
|
||||||
});
|
|
||||||
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">
|
||||||
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
|
<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 className="grid gap-4 md:grid-cols-2">
|
||||||
</div>
|
<ConnectionForm />
|
||||||
)}
|
<ServerStatus />
|
||||||
|
</div>
|
||||||
{activeTab === 'history' && (
|
{isTauri() && <UpdateStatus />}
|
||||||
<div className="space-y-4">
|
<ModelManagement />
|
||||||
<HistoryTable />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'settings' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<ConnectionForm />
|
|
||||||
<ServerStatus />
|
|
||||||
</div>
|
</div>
|
||||||
<ModelManagement />
|
) : (
|
||||||
</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">
|
||||||
</div>
|
{/* Left Column */}
|
||||||
</main>
|
<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>
|
||||||
|
</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}
|
<FormLabel>Voice Profile</FormLabel>
|
||||||
name="profileId"
|
{selectedProfile ? (
|
||||||
render={({ field }) => (
|
<div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
|
||||||
<FormItem>
|
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||||
<FormLabel>Voice Profile</FormLabel>
|
<span className="font-medium">{selectedProfile.name}</span>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Badge variant="outline">{selectedProfile.language}</Badge>
|
||||||
<FormControl>
|
</div>
|
||||||
<SelectTrigger>
|
) : (
|
||||||
<SelectValue placeholder="Select a voice" />
|
<div className="mt-2 p-3 border border-dashed rounded-md text-sm text-muted-foreground">
|
||||||
</SelectTrigger>
|
Click on a profile card above to select a voice profile
|
||||||
</FormControl>
|
</div>
|
||||||
<SelectContent>
|
|
||||||
{profiles?.map((profile) => (
|
|
||||||
<SelectItem key={profile.id} value={profile.id}>
|
|
||||||
{profile.name}
|
|
||||||
</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(
|
||||||
<TableRow>
|
'flex-1 min-h-0 overflow-y-auto border rounded-md overflow-x-hidden',
|
||||||
<TableHead>Text</TableHead>
|
isPlayerVisible && 'max-h-[calc(100vh-220px)]',
|
||||||
<TableHead>Profile</TableHead>
|
)}
|
||||||
<TableHead>Language</TableHead>
|
>
|
||||||
<TableHead>Duration</TableHead>
|
<Table className="w-full table-fixed">
|
||||||
<TableHead>Created</TableHead>
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableRow>
|
||||||
</TableRow>
|
<TableHead className="w-[38%]">Input</TableHead>
|
||||||
</TableHeader>
|
<TableHead className="w-[13%]">Voice</TableHead>
|
||||||
<TableBody>
|
<TableHead className="w-[9%]">Lang</TableHead>
|
||||||
{history.map((gen) => (
|
<TableHead className="w-[9%]">Length</TableHead>
|
||||||
<TableRow key={gen.id}>
|
<TableHead className="w-[13%]">Date</TableHead>
|
||||||
<TableCell className="max-w-[300px] truncate">{gen.text}</TableCell>
|
<TableHead className="w-[8%] text-right"></TableHead>
|
||||||
<TableCell>{gen.profile_name}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline">{gen.language}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatDuration(gen.duration)}</TableCell>
|
|
||||||
<TableCell>{formatDate(gen.created_at)}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handlePlay(gen.id)}
|
|
||||||
aria-label="Play audio"
|
|
||||||
>
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handleDownload(gen.id, gen.text)}
|
|
||||||
aria-label="Download audio"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => deleteGeneration.mutate(gen.id)}
|
|
||||||
disabled={deleteGeneration.isPending}
|
|
||||||
aria-label="Delete generation"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{history.map((gen) => {
|
||||||
|
const isCurrentlyPlaying = currentAudioId === gen.id && isPlaying;
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<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 className="text-right">
|
||||||
|
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 rounded-full"
|
||||||
|
aria-label="Actions"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handlePlay(gen.id, gen.text)}>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Play
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleDownload(gen.id, gen.text)}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => deleteGeneration.mutate(gen.id)}
|
||||||
|
disabled={deleteGeneration.isPending}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</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" />
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data) as ModelProgressType;
|
const data = JSON.parse(event.data) as ModelProgressType;
|
||||||
setProgress(data);
|
setProgress(data);
|
||||||
|
|
||||||
// Close connection if complete or error
|
// Close connection if complete or error
|
||||||
if (data.status === 'complete' || data.status === 'error') {
|
if (data.status === 'complete' || data.status === 'error') {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
@@ -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,32 +8,37 @@ 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) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
const isActive = activeTab === tab.id;
|
const isActive = activeTab === tab.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
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);
|
||||||
) {
|
};
|
||||||
deleteProfile.mutate(profile.id);
|
|
||||||
}
|
const handleDeleteConfirm = () => {
|
||||||
|
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
|
||||||
name: z.string().min(1, 'Name is required').max(100),
|
async function getAudioDuration(file: File): Promise<number> {
|
||||||
description: z.string().max(500).optional(),
|
return new Promise((resolve, reject) => {
|
||||||
language: z.enum(['en', 'zh']),
|
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),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
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
|
||||||
toast({
|
const sampleFile = form.getValues('sampleFile');
|
||||||
title: 'Profile created',
|
const referenceText = form.getValues('referenceText');
|
||||||
description: `"${data.name}" has been created successfully.`,
|
|
||||||
|
// 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({
|
||||||
|
title: 'Profile created',
|
||||||
|
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,79 +340,309 @@ 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)}>
|
||||||
<FormField
|
<div className={`grid gap-6 ${isCreating ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||||
control={form.control}
|
{/* Left column: Profile info */}
|
||||||
name="name"
|
<div className="space-y-4">
|
||||||
render={({ field }) => (
|
<FormField
|
||||||
<FormItem>
|
control={form.control}
|
||||||
<FormLabel>Name</FormLabel>
|
name="name"
|
||||||
<FormControl>
|
render={({ field }) => (
|
||||||
<Input placeholder="My Voice" {...field} />
|
<FormItem>
|
||||||
</FormControl>
|
<FormLabel>Name</FormLabel>
|
||||||
<FormMessage />
|
<FormControl>
|
||||||
</FormItem>
|
<Input placeholder="My Voice" {...field} />
|
||||||
)}
|
</FormControl>
|
||||||
/>
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
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>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="language"
|
name="language"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Language</FormLabel>
|
<FormLabel>Language</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="en">English</SelectItem>
|
<SelectItem value="en">English</SelectItem>
|
||||||
<SelectItem value="zh">Chinese</SelectItem>
|
<SelectItem value="zh">Chinese</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</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,36 +26,40 @@ 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">
|
||||||
<Card>
|
{allProfiles.length === 0 ? (
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<Card>
|
||||||
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<p className="text-muted-foreground mb-4">
|
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
No voice profiles yet. Create your first profile to get started.
|
<p className="text-muted-foreground mb-4">
|
||||||
</p>
|
No voice profiles yet. Create your first profile to get started.
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
</p>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
Create Profile
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
Create Profile
|
||||||
</CardContent>
|
</Button>
|
||||||
</Card>
|
</CardContent>
|
||||||
) : (
|
</Card>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
) : (
|
||||||
{profiles?.map((profile) => (
|
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1">
|
||||||
<ProfileCard key={profile.id} profile={profile} />
|
{allProfiles.map((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,32 +176,156 @@ 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">
|
||||||
<FormField
|
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record')}>
|
||||||
control={form.control}
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
name="file"
|
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||||
render={({ field: { onChange, value, ...field } }) => (
|
<Upload className="h-4 w-4" />
|
||||||
<FormItem>
|
Upload
|
||||||
<FormLabel>Audio File</FormLabel>
|
</TabsTrigger>
|
||||||
<FormControl>
|
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<Mic className="h-4 w-4" />
|
||||||
<Input
|
Record
|
||||||
type="file"
|
</TabsTrigger>
|
||||||
accept="audio/*"
|
</TabsList>
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
<TabsContent value="upload" className="space-y-4">
|
||||||
if (file) {
|
<FormField
|
||||||
onChange(file);
|
control={form.control}
|
||||||
}
|
name="file"
|
||||||
}}
|
render={({ field: { onChange, value, ...field } }) => (
|
||||||
{...field}
|
<FormItem>
|
||||||
/>
|
<FormLabel>Audio File</FormLabel>
|
||||||
</div>
|
<FormControl>
|
||||||
</FormControl>
|
<div className="flex items-center gap-2">
|
||||||
<FormDescription>Supported formats: WAV, MP3, M4A</FormDescription>
|
<Input
|
||||||
<FormMessage />
|
type="file"
|
||||||
</FormItem>
|
accept="audio/*"
|
||||||
)}
|
onChange={(e) => {
|
||||||
/>
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
onChange(file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...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>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Supported formats: WAV, MP3, M4A. 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="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) {
|
||||||
|
|||||||
@@ -6,20 +6,20 @@ import type { ApiRequestOptions } from './ApiRequestOptions';
|
|||||||
import type { ApiResult } from './ApiResult';
|
import type { ApiResult } from './ApiResult';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
public readonly url: string;
|
public readonly url: string;
|
||||||
public readonly status: number;
|
public readonly status: number;
|
||||||
public readonly statusText: string;
|
public readonly statusText: string;
|
||||||
public readonly body: any;
|
public readonly body: any;
|
||||||
public readonly request: ApiRequestOptions;
|
public readonly request: ApiRequestOptions;
|
||||||
|
|
||||||
constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
|
constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|
||||||
this.name = 'ApiError';
|
this.name = 'ApiError';
|
||||||
this.url = response.url;
|
this.url = response.url;
|
||||||
this.status = response.status;
|
this.status = response.status;
|
||||||
this.statusText = response.statusText;
|
this.statusText = response.statusText;
|
||||||
this.body = response.body;
|
this.body = response.body;
|
||||||
this.request = request;
|
this.request = request;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export type ApiRequestOptions = {
|
export type ApiRequestOptions = {
|
||||||
readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
|
readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
readonly path?: Record<string, any>;
|
readonly path?: Record<string, any>;
|
||||||
readonly cookies?: Record<string, any>;
|
readonly cookies?: Record<string, any>;
|
||||||
readonly headers?: Record<string, any>;
|
readonly headers?: Record<string, any>;
|
||||||
readonly query?: Record<string, any>;
|
readonly query?: Record<string, any>;
|
||||||
readonly formData?: Record<string, any>;
|
readonly formData?: Record<string, any>;
|
||||||
readonly body?: any;
|
readonly body?: any;
|
||||||
readonly mediaType?: string;
|
readonly mediaType?: string;
|
||||||
readonly responseHeader?: string;
|
readonly responseHeader?: string;
|
||||||
readonly errors?: Record<number, string>;
|
readonly errors?: Record<number, string>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export type ApiResult = {
|
export type ApiResult = {
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
readonly ok: boolean;
|
readonly ok: boolean;
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
readonly statusText: string;
|
readonly statusText: string;
|
||||||
readonly body: any;
|
readonly body: any;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,129 +3,128 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export class CancelError extends Error {
|
export class CancelError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'CancelError';
|
||||||
|
}
|
||||||
|
|
||||||
constructor(message: string) {
|
public get isCancelled(): boolean {
|
||||||
super(message);
|
return true;
|
||||||
this.name = 'CancelError';
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public get isCancelled(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OnCancel {
|
export interface OnCancel {
|
||||||
readonly isResolved: boolean;
|
readonly isResolved: boolean;
|
||||||
readonly isRejected: boolean;
|
readonly isRejected: boolean;
|
||||||
readonly isCancelled: boolean;
|
readonly isCancelled: boolean;
|
||||||
|
|
||||||
(cancelHandler: () => void): void;
|
(cancelHandler: () => void): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CancelablePromise<T> implements Promise<T> {
|
export class CancelablePromise<T> implements Promise<T> {
|
||||||
#isResolved: boolean;
|
#isResolved: boolean;
|
||||||
#isRejected: boolean;
|
#isRejected: boolean;
|
||||||
#isCancelled: boolean;
|
#isCancelled: boolean;
|
||||||
readonly #cancelHandlers: (() => void)[];
|
readonly #cancelHandlers: (() => void)[];
|
||||||
readonly #promise: Promise<T>;
|
readonly #promise: Promise<T>;
|
||||||
#resolve?: (value: T | PromiseLike<T>) => void;
|
#resolve?: (value: T | PromiseLike<T>) => void;
|
||||||
#reject?: (reason?: any) => void;
|
#reject?: (reason?: any) => void;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
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;
|
||||||
this.#isCancelled = false;
|
this.#isCancelled = false;
|
||||||
this.#cancelHandlers = [];
|
this.#cancelHandlers = [];
|
||||||
this.#promise = new Promise<T>((resolve, reject) => {
|
this.#promise = new Promise<T>((resolve, reject) => {
|
||||||
this.#resolve = resolve;
|
this.#resolve = resolve;
|
||||||
this.#reject = reject;
|
this.#reject = reject;
|
||||||
|
|
||||||
const onResolve = (value: T | PromiseLike<T>): void => {
|
const onResolve = (value: T | PromiseLike<T>): void => {
|
||||||
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#isResolved = true;
|
|
||||||
if (this.#resolve) this.#resolve(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onReject = (reason?: any): void => {
|
|
||||||
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#isRejected = true;
|
|
||||||
if (this.#reject) this.#reject(reason);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCancel = (cancelHandler: () => void): void => {
|
|
||||||
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#cancelHandlers.push(cancelHandler);
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.defineProperty(onCancel, 'isResolved', {
|
|
||||||
get: (): boolean => this.#isResolved,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(onCancel, 'isRejected', {
|
|
||||||
get: (): boolean => this.#isRejected,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(onCancel, 'isCancelled', {
|
|
||||||
get: (): boolean => this.#isCancelled,
|
|
||||||
});
|
|
||||||
|
|
||||||
return executor(onResolve, onReject, onCancel as OnCancel);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get [Symbol.toStringTag]() {
|
|
||||||
return "Cancellable Promise";
|
|
||||||
}
|
|
||||||
|
|
||||||
public then<TResult1 = T, TResult2 = never>(
|
|
||||||
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
|
|
||||||
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
|
|
||||||
): Promise<TResult1 | TResult2> {
|
|
||||||
return this.#promise.then(onFulfilled, onRejected);
|
|
||||||
}
|
|
||||||
|
|
||||||
public catch<TResult = never>(
|
|
||||||
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
|
|
||||||
): Promise<T | TResult> {
|
|
||||||
return this.#promise.catch(onRejected);
|
|
||||||
}
|
|
||||||
|
|
||||||
public finally(onFinally?: (() => void) | null): Promise<T> {
|
|
||||||
return this.#promise.finally(onFinally);
|
|
||||||
}
|
|
||||||
|
|
||||||
public cancel(): void {
|
|
||||||
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.#isCancelled = true;
|
this.#isResolved = true;
|
||||||
if (this.#cancelHandlers.length) {
|
if (this.#resolve) this.#resolve(value);
|
||||||
try {
|
};
|
||||||
for (const cancelHandler of this.#cancelHandlers) {
|
|
||||||
cancelHandler();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Cancellation threw an error', error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.#cancelHandlers.length = 0;
|
|
||||||
if (this.#reject) this.#reject(new CancelError('Request aborted'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public get isCancelled(): boolean {
|
const onReject = (reason?: any): void => {
|
||||||
return this.#isCancelled;
|
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#isRejected = true;
|
||||||
|
if (this.#reject) this.#reject(reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = (cancelHandler: () => void): void => {
|
||||||
|
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#cancelHandlers.push(cancelHandler);
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(onCancel, 'isResolved', {
|
||||||
|
get: (): boolean => this.#isResolved,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(onCancel, 'isRejected', {
|
||||||
|
get: (): boolean => this.#isRejected,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(onCancel, 'isCancelled', {
|
||||||
|
get: (): boolean => this.#isCancelled,
|
||||||
|
});
|
||||||
|
|
||||||
|
return executor(onResolve, onReject, onCancel as OnCancel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get [Symbol.toStringTag]() {
|
||||||
|
return 'Cancellable Promise';
|
||||||
|
}
|
||||||
|
|
||||||
|
public then<TResult1 = T, TResult2 = never>(
|
||||||
|
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
|
||||||
|
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
|
||||||
|
): Promise<TResult1 | TResult2> {
|
||||||
|
return this.#promise.then(onFulfilled, onRejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
public catch<TResult = never>(
|
||||||
|
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null,
|
||||||
|
): Promise<T | TResult> {
|
||||||
|
return this.#promise.catch(onRejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
public finally(onFinally?: (() => void) | null): Promise<T> {
|
||||||
|
return this.#promise.finally(onFinally);
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancel(): void {
|
||||||
|
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.#isCancelled = true;
|
||||||
|
if (this.#cancelHandlers.length) {
|
||||||
|
try {
|
||||||
|
for (const cancelHandler of this.#cancelHandlers) {
|
||||||
|
cancelHandler();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Cancellation threw an error', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#cancelHandlers.length = 0;
|
||||||
|
if (this.#reject) this.#reject(new CancelError('Request aborted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isCancelled(): boolean {
|
||||||
|
return this.#isCancelled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,25 +8,25 @@ type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
|
|||||||
type Headers = Record<string, string>;
|
type Headers = Record<string, string>;
|
||||||
|
|
||||||
export type OpenAPIConfig = {
|
export type OpenAPIConfig = {
|
||||||
BASE: string;
|
BASE: string;
|
||||||
VERSION: string;
|
VERSION: string;
|
||||||
WITH_CREDENTIALS: boolean;
|
WITH_CREDENTIALS: boolean;
|
||||||
CREDENTIALS: 'include' | 'omit' | 'same-origin';
|
CREDENTIALS: 'include' | 'omit' | 'same-origin';
|
||||||
TOKEN?: string | Resolver<string> | undefined;
|
TOKEN?: string | Resolver<string> | undefined;
|
||||||
USERNAME?: string | Resolver<string> | undefined;
|
USERNAME?: string | Resolver<string> | undefined;
|
||||||
PASSWORD?: string | Resolver<string> | undefined;
|
PASSWORD?: string | Resolver<string> | undefined;
|
||||||
HEADERS?: Headers | Resolver<Headers> | undefined;
|
HEADERS?: Headers | Resolver<Headers> | undefined;
|
||||||
ENCODE_PATH?: ((path: string) => string) | undefined;
|
ENCODE_PATH?: ((path: string) => string) | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OpenAPI: OpenAPIConfig = {
|
export const OpenAPI: OpenAPIConfig = {
|
||||||
BASE: '',
|
BASE: '',
|
||||||
VERSION: '0.1.0',
|
VERSION: '0.1.0',
|
||||||
WITH_CREDENTIALS: false,
|
WITH_CREDENTIALS: false,
|
||||||
CREDENTIALS: 'include',
|
CREDENTIALS: 'include',
|
||||||
TOKEN: undefined,
|
TOKEN: undefined,
|
||||||
USERNAME: undefined,
|
USERNAME: undefined,
|
||||||
PASSWORD: undefined,
|
PASSWORD: undefined,
|
||||||
HEADERS: undefined,
|
HEADERS: undefined,
|
||||||
ENCODE_PATH: undefined,
|
ENCODE_PATH: undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
+247
-228
@@ -9,278 +9,294 @@ 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>(
|
||||||
return value !== undefined && value !== null;
|
value: T | null | undefined,
|
||||||
|
): value is Exclude<T, null | undefined> => {
|
||||||
|
return value !== undefined && value !== null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isString = (value: any): value is string => {
|
export const isString = (value: any): value is string => {
|
||||||
return typeof value === 'string';
|
return typeof value === 'string';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isStringWithValue = (value: any): value is string => {
|
export const isStringWithValue = (value: any): value is string => {
|
||||||
return isString(value) && value !== '';
|
return isString(value) && value !== '';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isBlob = (value: any): value is Blob => {
|
export const isBlob = (value: any): value is Blob => {
|
||||||
return (
|
return (
|
||||||
typeof value === 'object' &&
|
typeof value === 'object' &&
|
||||||
typeof value.type === 'string' &&
|
typeof value.type === 'string' &&
|
||||||
typeof value.stream === 'function' &&
|
typeof value.stream === 'function' &&
|
||||||
typeof value.arrayBuffer === 'function' &&
|
typeof value.arrayBuffer === 'function' &&
|
||||||
typeof value.constructor === 'function' &&
|
typeof value.constructor === 'function' &&
|
||||||
typeof value.constructor.name === 'string' &&
|
typeof value.constructor.name === 'string' &&
|
||||||
/^(Blob|File)$/.test(value.constructor.name) &&
|
/^(Blob|File)$/.test(value.constructor.name) &&
|
||||||
/^(Blob|File)$/.test(value[Symbol.toStringTag])
|
/^(Blob|File)$/.test(value[Symbol.toStringTag])
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isFormData = (value: any): value is FormData => {
|
export const isFormData = (value: any): value is FormData => {
|
||||||
return value instanceof FormData;
|
return value instanceof FormData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const base64 = (str: string): string => {
|
export const base64 = (str: string): string => {
|
||||||
try {
|
try {
|
||||||
return btoa(str);
|
return btoa(str);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
return Buffer.from(str).toString('base64');
|
return Buffer.from(str).toString('base64');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getQueryString = (params: Record<string, any>): string => {
|
export const getQueryString = (params: Record<string, any>): string => {
|
||||||
const qs: string[] = [];
|
const qs: string[] = [];
|
||||||
|
|
||||||
const append = (key: string, value: any) => {
|
const append = (key: string, value: any) => {
|
||||||
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
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') {
|
||||||
Object.entries(value).forEach(([k, v]) => {
|
Object.entries(value).forEach(([k, v]) => {
|
||||||
process(`${key}[${k}]`, v);
|
process(`${key}[${k}]`, v);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
append(key, value);
|
append(key, value);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
|
||||||
process(key, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (qs.length > 0) {
|
|
||||||
return `?${qs.join('&')}`;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return '';
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
process(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (qs.length > 0) {
|
||||||
|
return `?${qs.join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
|
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
|
||||||
const encoder = config.ENCODE_PATH || encodeURI;
|
const encoder = config.ENCODE_PATH || encodeURI;
|
||||||
|
|
||||||
const path = options.url
|
const path = options.url
|
||||||
.replace('{api-version}', config.VERSION)
|
.replace('{api-version}', config.VERSION)
|
||||||
.replace(/{(.*?)}/g, (substring: string, group: string) => {
|
.replace(/{(.*?)}/g, (substring: string, group: string) => {
|
||||||
if (options.path?.hasOwnProperty(group)) {
|
if (options.path?.hasOwnProperty(group)) {
|
||||||
return encoder(String(options.path[group]));
|
return encoder(String(options.path[group]));
|
||||||
}
|
}
|
||||||
return substring;
|
return substring;
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = `${config.BASE}${path}`;
|
const url = `${config.BASE}${path}`;
|
||||||
if (options.query) {
|
if (options.query) {
|
||||||
return `${url}${getQueryString(options.query)}`;
|
return `${url}${getQueryString(options.query)}`;
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
|
export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
|
||||||
if (options.formData) {
|
if (options.formData) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
const process = (key: string, value: any) => {
|
const process = (key: string, value: any) => {
|
||||||
if (isString(value) || isBlob(value)) {
|
if (isString(value) || isBlob(value)) {
|
||||||
formData.append(key, value);
|
formData.append(key, value);
|
||||||
} else {
|
} else {
|
||||||
formData.append(key, JSON.stringify(value));
|
formData.append(key, JSON.stringify(value));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.entries(options.formData)
|
Object.entries(options.formData)
|
||||||
.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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
return undefined;
|
return 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>(
|
||||||
if (typeof resolver === 'function') {
|
options: ApiRequestOptions,
|
||||||
return (resolver as Resolver<T>)(options);
|
resolver?: T | Resolver<T>,
|
||||||
}
|
): Promise<T | undefined> => {
|
||||||
return resolver;
|
if (typeof resolver === 'function') {
|
||||||
|
return (resolver as Resolver<T>)(options);
|
||||||
|
}
|
||||||
|
return resolver;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Promise<Headers> => {
|
export const getHeaders = async (
|
||||||
const [token, username, password, additionalHeaders] = await Promise.all([
|
config: OpenAPIConfig,
|
||||||
resolve(options, config.TOKEN),
|
options: ApiRequestOptions,
|
||||||
resolve(options, config.USERNAME),
|
): Promise<Headers> => {
|
||||||
resolve(options, config.PASSWORD),
|
const [token, username, password, additionalHeaders] = await Promise.all([
|
||||||
resolve(options, config.HEADERS),
|
resolve(options, config.TOKEN),
|
||||||
]);
|
resolve(options, config.USERNAME),
|
||||||
|
resolve(options, config.PASSWORD),
|
||||||
|
resolve(options, config.HEADERS),
|
||||||
|
]);
|
||||||
|
|
||||||
const headers = Object.entries({
|
const headers = Object.entries({
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
...additionalHeaders,
|
...additionalHeaders,
|
||||||
...options.headers,
|
...options.headers,
|
||||||
})
|
})
|
||||||
.filter(([_, value]) => isDefined(value))
|
.filter(([_, value]) => isDefined(value))
|
||||||
.reduce((headers, [key, value]) => ({
|
.reduce(
|
||||||
...headers,
|
(headers, [key, value]) => ({
|
||||||
[key]: String(value),
|
...headers,
|
||||||
}), {} as Record<string, string>);
|
[key]: String(value),
|
||||||
|
}),
|
||||||
|
{} as Record<string, string>,
|
||||||
|
);
|
||||||
|
|
||||||
if (isStringWithValue(token)) {
|
if (isStringWithValue(token)) {
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStringWithValue(username) && isStringWithValue(password)) {
|
||||||
|
const credentials = base64(`${username}:${password}`);
|
||||||
|
headers['Authorization'] = `Basic ${credentials}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.body !== undefined) {
|
||||||
|
if (options.mediaType) {
|
||||||
|
headers['Content-Type'] = options.mediaType;
|
||||||
|
} else if (isBlob(options.body)) {
|
||||||
|
headers['Content-Type'] = options.body.type || 'application/octet-stream';
|
||||||
|
} else if (isString(options.body)) {
|
||||||
|
headers['Content-Type'] = 'text/plain';
|
||||||
|
} else if (!isFormData(options.body)) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isStringWithValue(username) && isStringWithValue(password)) {
|
return new Headers(headers);
|
||||||
const credentials = base64(`${username}:${password}`);
|
|
||||||
headers['Authorization'] = `Basic ${credentials}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.body !== undefined) {
|
|
||||||
if (options.mediaType) {
|
|
||||||
headers['Content-Type'] = options.mediaType;
|
|
||||||
} else if (isBlob(options.body)) {
|
|
||||||
headers['Content-Type'] = options.body.type || 'application/octet-stream';
|
|
||||||
} else if (isString(options.body)) {
|
|
||||||
headers['Content-Type'] = 'text/plain';
|
|
||||||
} else if (!isFormData(options.body)) {
|
|
||||||
headers['Content-Type'] = 'application/json';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Headers(headers);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 {
|
||||||
return JSON.stringify(options.body);
|
return JSON.stringify(options.body);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return undefined;
|
}
|
||||||
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendRequest = async (
|
export const sendRequest = async (
|
||||||
config: OpenAPIConfig,
|
config: OpenAPIConfig,
|
||||||
options: ApiRequestOptions,
|
options: ApiRequestOptions,
|
||||||
url: string,
|
url: string,
|
||||||
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();
|
||||||
|
|
||||||
const request: RequestInit = {
|
const request: RequestInit = {
|
||||||
headers,
|
headers,
|
||||||
body: body ?? formData,
|
body: body ?? formData,
|
||||||
method: options.method,
|
method: options.method,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.WITH_CREDENTIALS) {
|
if (config.WITH_CREDENTIALS) {
|
||||||
request.credentials = config.CREDENTIALS;
|
request.credentials = config.CREDENTIALS;
|
||||||
}
|
}
|
||||||
|
|
||||||
onCancel(() => controller.abort());
|
onCancel(() => controller.abort());
|
||||||
|
|
||||||
return await fetch(url, request);
|
return await fetch(url, request);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getResponseHeader = (response: Response, responseHeader?: string): string | undefined => {
|
export const getResponseHeader = (
|
||||||
if (responseHeader) {
|
response: Response,
|
||||||
const content = response.headers.get(responseHeader);
|
responseHeader?: string,
|
||||||
if (isString(content)) {
|
): string | undefined => {
|
||||||
return content;
|
if (responseHeader) {
|
||||||
}
|
const content = response.headers.get(responseHeader);
|
||||||
|
if (isString(content)) {
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
return undefined;
|
}
|
||||||
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getResponseBody = async (response: Response): Promise<any> => {
|
export const getResponseBody = async (response: Response): Promise<any> => {
|
||||||
if (response.status !== 204) {
|
if (response.status !== 204) {
|
||||||
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 {
|
||||||
return await response.text();
|
return await response.text();
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
return undefined;
|
}
|
||||||
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
|
export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
|
||||||
const errors: Record<number, string> = {
|
const errors: Record<number, string> = {
|
||||||
400: 'Bad Request',
|
400: 'Bad Request',
|
||||||
401: 'Unauthorized',
|
401: 'Unauthorized',
|
||||||
403: 'Forbidden',
|
403: 'Forbidden',
|
||||||
404: 'Not Found',
|
404: 'Not Found',
|
||||||
500: 'Internal Server Error',
|
500: 'Internal Server Error',
|
||||||
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) {
|
||||||
throw new ApiError(options, result, error);
|
throw new ApiError(options, result, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const errorStatus = result.status ?? 'unknown';
|
const errorStatus = result.status ?? 'unknown';
|
||||||
const errorStatusText = result.statusText ?? 'unknown';
|
const errorStatusText = result.statusText ?? 'unknown';
|
||||||
const errorBody = (() => {
|
const errorBody = (() => {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(result.body, null, 2);
|
return JSON.stringify(result.body, null, 2);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
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,33 +306,36 @@ 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>(
|
||||||
return new CancelablePromise(async (resolve, reject, onCancel) => {
|
config: OpenAPIConfig,
|
||||||
try {
|
options: ApiRequestOptions,
|
||||||
const url = getUrl(config, options);
|
): CancelablePromise<T> => {
|
||||||
const formData = getFormData(options);
|
return new CancelablePromise(async (resolve, reject, onCancel) => {
|
||||||
const body = getRequestBody(options);
|
try {
|
||||||
const headers = await getHeaders(config, options);
|
const url = getUrl(config, options);
|
||||||
|
const formData = getFormData(options);
|
||||||
|
const body = getRequestBody(options);
|
||||||
|
const headers = await getHeaders(config, options);
|
||||||
|
|
||||||
if (!onCancel.isCancelled) {
|
if (!onCancel.isCancelled) {
|
||||||
const response = await sendRequest(config, options, url, body, formData, headers, onCancel);
|
const response = await sendRequest(config, options, url, body, formData, headers, onCancel);
|
||||||
const responseBody = await getResponseBody(response);
|
const responseBody = await getResponseBody(response);
|
||||||
const responseHeader = getResponseHeader(response, options.responseHeader);
|
const responseHeader = getResponseHeader(response, options.responseHeader);
|
||||||
|
|
||||||
const result: ApiResult = {
|
const result: ApiResult = {
|
||||||
url,
|
url,
|
||||||
ok: response.ok,
|
ok: response.ok,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
body: responseHeader ?? responseBody,
|
body: responseHeader ?? responseBody,
|
||||||
};
|
};
|
||||||
|
|
||||||
catchErrorCodes(options, result);
|
catchErrorCodes(options, result);
|
||||||
|
|
||||||
resolve(result.body);
|
resolve(result.body);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-3
@@ -3,7 +3,6 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export type Body_add_profile_sample_profiles__profile_id__samples_post = {
|
export type Body_add_profile_sample_profiles__profile_id__samples_post = {
|
||||||
file: Blob;
|
file: Blob;
|
||||||
reference_text: string;
|
reference_text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
* Request model for voice generation.
|
* Request model for voice generation.
|
||||||
*/
|
*/
|
||||||
export type GenerationRequest = {
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
* Response model for voice generation.
|
* Response model for voice generation.
|
||||||
*/
|
*/
|
||||||
export type GenerationResponse = {
|
export type GenerationResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
text: string;
|
text: string;
|
||||||
language: string;
|
language: string;
|
||||||
audio_path: string;
|
audio_path: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
seed: (number | null);
|
seed: number | null;
|
||||||
created_at: string;
|
instruct: string | null;
|
||||||
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,5 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import type { ValidationError } from './ValidationError';
|
import type { ValidationError } from './ValidationError';
|
||||||
export type HTTPValidationError = {
|
export type HTTPValidationError = {
|
||||||
detail?: Array<ValidationError>;
|
detail?: Array<ValidationError>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,10 @@
|
|||||||
* Response model for health check.
|
* Response model for health check.
|
||||||
*/
|
*/
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { HistoryResponse } from './HistoryResponse';
|
|||||||
* Response model for history list.
|
* Response model for history list.
|
||||||
*/
|
*/
|
||||||
export type HistoryListResponse = {
|
export type HistoryListResponse = {
|
||||||
items: Array<HistoryResponse>;
|
items: Array<HistoryResponse>;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
* Response model for history entry (includes profile name).
|
* Response model for history entry (includes profile name).
|
||||||
*/
|
*/
|
||||||
export type HistoryResponse = {
|
export type HistoryResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
profile_name: string;
|
profile_name: string;
|
||||||
text: string;
|
text: string;
|
||||||
language: string;
|
language: string;
|
||||||
audio_path: string;
|
audio_path: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
seed: (number | null);
|
seed: number | null;
|
||||||
created_at: string;
|
instruct: string | null;
|
||||||
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,5 @@
|
|||||||
* Request model for triggering model download.
|
* Request model for triggering model download.
|
||||||
*/
|
*/
|
||||||
export type ModelDownloadRequest = {
|
export type ModelDownloadRequest = {
|
||||||
model_name: string;
|
model_name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,9 @@
|
|||||||
* Response model for model status.
|
* Response model for model status.
|
||||||
*/
|
*/
|
||||||
export type ModelStatus = {
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,5 @@ import type { ModelStatus } from './ModelStatus';
|
|||||||
* Response model for model status list.
|
* Response model for model status list.
|
||||||
*/
|
*/
|
||||||
export type ModelStatusListResponse = {
|
export type ModelStatusListResponse = {
|
||||||
models: Array<ModelStatus>;
|
models: Array<ModelStatus>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,8 @@
|
|||||||
* Response model for profile sample.
|
* Response model for profile sample.
|
||||||
*/
|
*/
|
||||||
export type ProfileSampleResponse = {
|
export type ProfileSampleResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
audio_path: string;
|
audio_path: string;
|
||||||
reference_text: string;
|
reference_text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
* Response model for transcription.
|
* Response model for transcription.
|
||||||
*/
|
*/
|
||||||
export type TranscriptionResponse = {
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
* Request model for creating a voice profile.
|
* Request model for creating a voice profile.
|
||||||
*/
|
*/
|
||||||
export type VoiceProfileCreate = {
|
export type VoiceProfileCreate = {
|
||||||
name: string;
|
name: string;
|
||||||
description?: (string | null);
|
description?: string | null;
|
||||||
language?: string;
|
language?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,10 @@
|
|||||||
* Response model for voice profile.
|
* Response model for voice profile.
|
||||||
*/
|
*/
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -3,15 +3,15 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $Body_add_profile_sample_profiles__profile_id__samples_post = {
|
export const $Body_add_profile_sample_profiles__profile_id__samples_post = {
|
||||||
properties: {
|
properties: {
|
||||||
file: {
|
file: {
|
||||||
type: 'binary',
|
type: 'binary',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
format: 'binary',
|
format: 'binary',
|
||||||
},
|
|
||||||
reference_text: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
reference_text: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,19 +3,22 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $Body_transcribe_audio_transcribe_post = {
|
export const $Body_transcribe_audio_transcribe_post = {
|
||||||
properties: {
|
properties: {
|
||||||
file: {
|
file: {
|
||||||
type: 'binary',
|
type: 'binary',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
format: 'binary',
|
format: 'binary',
|
||||||
},
|
|
||||||
language: {
|
|
||||||
type: 'any-of',
|
|
||||||
contains: [{
|
|
||||||
type: 'string',
|
|
||||||
}, {
|
|
||||||
type: 'null',
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
language: {
|
||||||
|
type: 'any-of',
|
||||||
|
contains: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,38 +3,44 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $GenerationRequest = {
|
export const $GenerationRequest = {
|
||||||
description: `Request model for voice generation.`,
|
description: `Request model for voice generation.`,
|
||||||
properties: {
|
properties: {
|
||||||
profile_id: {
|
profile_id: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
|
||||||
text: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
maxLength: 5000,
|
|
||||||
minLength: 1,
|
|
||||||
},
|
|
||||||
language: {
|
|
||||||
type: 'string',
|
|
||||||
pattern: '^(en|zh)$',
|
|
||||||
},
|
|
||||||
seed: {
|
|
||||||
type: 'any-of',
|
|
||||||
contains: [{
|
|
||||||
type: 'number',
|
|
||||||
}, {
|
|
||||||
type: 'null',
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
model_size: {
|
|
||||||
type: 'any-of',
|
|
||||||
contains: [{
|
|
||||||
type: 'string',
|
|
||||||
pattern: '^(1\\.7B|0\\.6B)$',
|
|
||||||
}, {
|
|
||||||
type: 'null',
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
text: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
maxLength: 5000,
|
||||||
|
minLength: 1,
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: 'string',
|
||||||
|
pattern: '^(en|zh)$',
|
||||||
|
},
|
||||||
|
seed: {
|
||||||
|
type: 'any-of',
|
||||||
|
contains: [
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
model_size: {
|
||||||
|
type: 'any-of',
|
||||||
|
contains: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
pattern: '^(1\\.7B|0\\.6B)$',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,45 +3,48 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $GenerationResponse = {
|
export const $GenerationResponse = {
|
||||||
description: `Response model for voice generation.`,
|
description: `Response model for voice generation.`,
|
||||||
properties: {
|
properties: {
|
||||||
id: {
|
id: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
|
||||||
profile_id: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
language: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
audio_path: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
duration: {
|
|
||||||
type: 'number',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
seed: {
|
|
||||||
type: 'any-of',
|
|
||||||
contains: [{
|
|
||||||
type: 'number',
|
|
||||||
}, {
|
|
||||||
type: 'null',
|
|
||||||
}],
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
created_at: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
format: 'date-time',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
profile_id: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
audio_path: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
type: 'number',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
seed: {
|
||||||
|
type: 'any-of',
|
||||||
|
contains: [
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
format: 'date-time',
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $HTTPValidationError = {
|
export const $HTTPValidationError = {
|
||||||
properties: {
|
properties: {
|
||||||
detail: {
|
detail: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
contains: {
|
contains: {
|
||||||
type: 'ValidationError',
|
type: 'ValidationError',
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,43 +3,52 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $HealthResponse = {
|
export const $HealthResponse = {
|
||||||
description: `Response model for health check.`,
|
description: `Response model for health check.`,
|
||||||
properties: {
|
properties: {
|
||||||
status: {
|
status: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
|
||||||
model_loaded: {
|
|
||||||
type: 'boolean',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
model_downloaded: {
|
|
||||||
type: 'any-of',
|
|
||||||
contains: [{
|
|
||||||
type: 'boolean',
|
|
||||||
}, {
|
|
||||||
type: 'null',
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
model_size: {
|
|
||||||
type: 'any-of',
|
|
||||||
contains: [{
|
|
||||||
type: 'string',
|
|
||||||
}, {
|
|
||||||
type: 'null',
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
gpu_available: {
|
|
||||||
type: 'boolean',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
vram_used_mb: {
|
|
||||||
type: 'any-of',
|
|
||||||
contains: [{
|
|
||||||
type: 'number',
|
|
||||||
}, {
|
|
||||||
type: 'null',
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
model_loaded: {
|
||||||
|
type: 'boolean',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
model_downloaded: {
|
||||||
|
type: 'any-of',
|
||||||
|
contains: [
|
||||||
|
{
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
model_size: {
|
||||||
|
type: 'any-of',
|
||||||
|
contains: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
gpu_available: {
|
||||||
|
type: 'boolean',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
vram_used_mb: {
|
||||||
|
type: 'any-of',
|
||||||
|
contains: [
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,18 +3,18 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $HistoryListResponse = {
|
export const $HistoryListResponse = {
|
||||||
description: `Response model for history list.`,
|
description: `Response model for history list.`,
|
||||||
properties: {
|
properties: {
|
||||||
items: {
|
items: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
contains: {
|
contains: {
|
||||||
type: 'HistoryResponse',
|
type: 'HistoryResponse',
|
||||||
},
|
},
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
|
||||||
total: {
|
|
||||||
type: 'number',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
total: {
|
||||||
|
type: 'number',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,49 +3,52 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $HistoryResponse = {
|
export const $HistoryResponse = {
|
||||||
description: `Response model for history entry (includes profile name).`,
|
description: `Response model for history entry (includes profile name).`,
|
||||||
properties: {
|
properties: {
|
||||||
id: {
|
id: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
|
||||||
profile_id: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
profile_name: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
language: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
audio_path: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
duration: {
|
|
||||||
type: 'number',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
seed: {
|
|
||||||
type: 'any-of',
|
|
||||||
contains: [{
|
|
||||||
type: 'number',
|
|
||||||
}, {
|
|
||||||
type: 'null',
|
|
||||||
}],
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
created_at: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
format: 'date-time',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
profile_id: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
profile_name: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
audio_path: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
type: 'number',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
seed: {
|
||||||
|
type: 'any-of',
|
||||||
|
contains: [
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
format: 'date-time',
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $ModelDownloadRequest = {
|
export const $ModelDownloadRequest = {
|
||||||
description: `Request model for triggering model download.`,
|
description: `Request model for triggering model download.`,
|
||||||
properties: {
|
properties: {
|
||||||
model_name: {
|
model_name: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,30 +3,33 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $ModelStatus = {
|
export const $ModelStatus = {
|
||||||
description: `Response model for model status.`,
|
description: `Response model for model status.`,
|
||||||
properties: {
|
properties: {
|
||||||
model_name: {
|
model_name: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
|
||||||
display_name: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
downloaded: {
|
|
||||||
type: 'boolean',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
size_mb: {
|
|
||||||
type: 'any-of',
|
|
||||||
contains: [{
|
|
||||||
type: 'number',
|
|
||||||
}, {
|
|
||||||
type: 'null',
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
loaded: {
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
display_name: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
downloaded: {
|
||||||
|
type: 'boolean',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
size_mb: {
|
||||||
|
type: 'any-of',
|
||||||
|
contains: [
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
loaded: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $ModelStatusListResponse = {
|
export const $ModelStatusListResponse = {
|
||||||
description: `Response model for model status list.`,
|
description: `Response model for model status list.`,
|
||||||
properties: {
|
properties: {
|
||||||
models: {
|
models: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
contains: {
|
contains: {
|
||||||
type: 'ModelStatus',
|
type: 'ModelStatus',
|
||||||
},
|
},
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,23 +3,23 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $ProfileSampleResponse = {
|
export const $ProfileSampleResponse = {
|
||||||
description: `Response model for profile sample.`,
|
description: `Response model for profile sample.`,
|
||||||
properties: {
|
properties: {
|
||||||
id: {
|
id: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
|
||||||
profile_id: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
audio_path: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
reference_text: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
profile_id: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
audio_path: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
reference_text: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $TranscriptionResponse = {
|
export const $TranscriptionResponse = {
|
||||||
description: `Response model for transcription.`,
|
description: `Response model for transcription.`,
|
||||||
properties: {
|
properties: {
|
||||||
text: {
|
text: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
|
||||||
duration: {
|
|
||||||
type: 'number',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
duration: {
|
||||||
|
type: 'number',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,26 +3,29 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $ValidationError = {
|
export const $ValidationError = {
|
||||||
properties: {
|
properties: {
|
||||||
loc: {
|
loc: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
contains: {
|
contains: {
|
||||||
type: 'any-of',
|
type: 'any-of',
|
||||||
contains: [{
|
contains: [
|
||||||
type: 'string',
|
{
|
||||||
}, {
|
|
||||||
type: 'number',
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
msg: {
|
|
||||||
type: 'string',
|
type: 'string',
|
||||||
isRequired: true,
|
},
|
||||||
},
|
{
|
||||||
type: {
|
type: 'number',
|
||||||
type: 'string',
|
},
|
||||||
isRequired: true,
|
],
|
||||||
},
|
},
|
||||||
|
isRequired: true,
|
||||||
},
|
},
|
||||||
|
msg: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,26 +3,29 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $VoiceProfileCreate = {
|
export const $VoiceProfileCreate = {
|
||||||
description: `Request model for creating a voice profile.`,
|
description: `Request model for creating a voice profile.`,
|
||||||
properties: {
|
properties: {
|
||||||
name: {
|
name: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
maxLength: 100,
|
maxLength: 100,
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: 'any-of',
|
|
||||||
contains: [{
|
|
||||||
type: 'string',
|
|
||||||
maxLength: 500,
|
|
||||||
}, {
|
|
||||||
type: 'null',
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
language: {
|
|
||||||
type: 'string',
|
|
||||||
pattern: '^(en|zh)$',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
description: {
|
||||||
|
type: 'any-of',
|
||||||
|
contains: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: 'string',
|
||||||
|
pattern: '^(en|zh)$',
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -3,38 +3,41 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const $VoiceProfileResponse = {
|
export const $VoiceProfileResponse = {
|
||||||
description: `Response model for voice profile.`,
|
description: `Response model for voice profile.`,
|
||||||
properties: {
|
properties: {
|
||||||
id: {
|
id: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
|
||||||
name: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: 'any-of',
|
|
||||||
contains: [{
|
|
||||||
type: 'string',
|
|
||||||
}, {
|
|
||||||
type: 'null',
|
|
||||||
}],
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
language: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
created_at: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
format: 'date-time',
|
|
||||||
},
|
|
||||||
updated_at: {
|
|
||||||
type: 'string',
|
|
||||||
isRequired: true,
|
|
||||||
format: 'date-time',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'any-of',
|
||||||
|
contains: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
format: 'date-time',
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: 'string',
|
||||||
|
isRequired: true,
|
||||||
|
format: 'date-time',
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -19,441 +19,441 @@ import type { CancelablePromise } from '../core/CancelablePromise';
|
|||||||
import { OpenAPI } from '../core/OpenAPI';
|
import { OpenAPI } from '../core/OpenAPI';
|
||||||
import { request as __request } from '../core/request';
|
import { request as __request } from '../core/request';
|
||||||
export class DefaultService {
|
export class DefaultService {
|
||||||
/**
|
/**
|
||||||
* Root
|
* Root
|
||||||
* Root endpoint.
|
* Root endpoint.
|
||||||
* @returns any Successful Response
|
* @returns any Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
public static rootGet(): CancelablePromise<any> {
|
public static rootGet(): CancelablePromise<any> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/',
|
url: '/',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Health
|
* Health
|
||||||
* Health check endpoint.
|
* Health check endpoint.
|
||||||
* @returns HealthResponse Successful Response
|
* @returns HealthResponse Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
public static healthHealthGet(): CancelablePromise<HealthResponse> {
|
public static healthHealthGet(): CancelablePromise<HealthResponse> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/health',
|
url: '/health',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* List Profiles
|
* List Profiles
|
||||||
* List all voice profiles.
|
* List all voice profiles.
|
||||||
* @returns VoiceProfileResponse Successful Response
|
* @returns VoiceProfileResponse Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
public static listProfilesProfilesGet(): CancelablePromise<Array<VoiceProfileResponse>> {
|
public static listProfilesProfilesGet(): CancelablePromise<Array<VoiceProfileResponse>> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/profiles',
|
url: '/profiles',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Create Profile
|
* Create Profile
|
||||||
* Create a new voice profile.
|
* Create a new voice profile.
|
||||||
* @returns VoiceProfileResponse Successful Response
|
* @returns VoiceProfileResponse Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
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',
|
||||||
url: '/profiles',
|
url: '/profiles',
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
mediaType: 'application/json',
|
mediaType: 'application/json',
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Get Profile
|
* Get Profile
|
||||||
* Get a voice profile by ID.
|
* Get a voice profile by ID.
|
||||||
* @returns VoiceProfileResponse Successful Response
|
* @returns VoiceProfileResponse Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
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`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Update Profile
|
* Update Profile
|
||||||
* Update a voice profile.
|
* Update a voice profile.
|
||||||
* @returns VoiceProfileResponse Successful Response
|
* @returns VoiceProfileResponse Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
public static updateProfileProfilesProfileIdPut({
|
public static updateProfileProfilesProfileIdPut({
|
||||||
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',
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Delete Profile
|
* Delete Profile
|
||||||
* Delete a voice profile.
|
* Delete a voice profile.
|
||||||
* @returns any Successful Response
|
* @returns any Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
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`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Add Profile Sample
|
* Add Profile Sample
|
||||||
* Add a sample to a voice profile.
|
* Add a sample to a voice profile.
|
||||||
* @returns ProfileSampleResponse Successful Response
|
* @returns ProfileSampleResponse Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
public static addProfileSampleProfilesProfileIdSamplesPost({
|
public static addProfileSampleProfilesProfileIdSamplesPost({
|
||||||
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',
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Get Profile Samples
|
* Get Profile Samples
|
||||||
* Get all samples for a profile.
|
* Get all samples for a profile.
|
||||||
* @returns ProfileSampleResponse Successful Response
|
* @returns ProfileSampleResponse Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
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`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Delete Profile Sample
|
* Delete Profile Sample
|
||||||
* Delete a profile sample.
|
* Delete a profile sample.
|
||||||
* @returns any Successful Response
|
* @returns any Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
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`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Generate Speech
|
* Generate Speech
|
||||||
* Generate speech from text using a voice profile.
|
* Generate speech from text using a voice profile.
|
||||||
* @returns GenerationResponse Successful Response
|
* @returns GenerationResponse Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
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',
|
||||||
url: '/generate',
|
url: '/generate',
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
mediaType: 'application/json',
|
mediaType: 'application/json',
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* List History
|
* List History
|
||||||
* List generation history with optional filters.
|
* List generation history with optional filters.
|
||||||
* @returns HistoryListResponse Successful Response
|
* @returns HistoryListResponse Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
public static listHistoryHistoryGet({
|
public static listHistoryHistoryGet({
|
||||||
profileId,
|
profileId,
|
||||||
search,
|
search,
|
||||||
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`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Get Generation
|
* Get Generation
|
||||||
* Get a generation by ID.
|
* Get a generation by ID.
|
||||||
* @returns HistoryResponse Successful Response
|
* @returns HistoryResponse Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
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`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Delete Generation
|
* Delete Generation
|
||||||
* Delete a generation.
|
* Delete a generation.
|
||||||
* @returns any Successful Response
|
* @returns any Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
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`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Get Stats
|
* Get Stats
|
||||||
* Get generation statistics.
|
* Get generation statistics.
|
||||||
* @returns any Successful Response
|
* @returns any Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
public static getStatsHistoryStatsGet(): CancelablePromise<any> {
|
public static getStatsHistoryStatsGet(): CancelablePromise<any> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/history/stats',
|
url: '/history/stats',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Transcribe Audio
|
* Transcribe Audio
|
||||||
* Transcribe audio file to text.
|
* Transcribe audio file to text.
|
||||||
* @returns TranscriptionResponse Successful Response
|
* @returns TranscriptionResponse Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
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',
|
||||||
url: '/transcribe',
|
url: '/transcribe',
|
||||||
formData: formData,
|
formData: formData,
|
||||||
mediaType: 'multipart/form-data',
|
mediaType: 'multipart/form-data',
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Get Audio
|
* Get Audio
|
||||||
* Serve generated audio file.
|
* Serve generated audio file.
|
||||||
* @returns any Successful Response
|
* @returns any Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
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`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Load Model
|
* Load Model
|
||||||
* Manually load TTS model.
|
* Manually load TTS model.
|
||||||
* @returns any Successful Response
|
* @returns any Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
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`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Unload Model
|
* Unload Model
|
||||||
* Unload TTS model to free memory.
|
* Unload TTS model to free memory.
|
||||||
* @returns any Successful Response
|
* @returns any Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
public static unloadModelModelsUnloadPost(): CancelablePromise<any> {
|
public static unloadModelModelsUnloadPost(): CancelablePromise<any> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/models/unload',
|
url: '/models/unload',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Get Model Progress
|
* Get Model Progress
|
||||||
* Get model download progress via Server-Sent Events.
|
* Get model download progress via Server-Sent Events.
|
||||||
* @returns any Successful Response
|
* @returns any Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
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`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Get Model Status
|
* Get Model Status
|
||||||
* Get status of all available models.
|
* Get status of all available models.
|
||||||
* @returns ModelStatusListResponse Successful Response
|
* @returns ModelStatusListResponse Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
public static getModelStatusModelsStatusGet(): CancelablePromise<ModelStatusListResponse> {
|
public static getModelStatusModelsStatusGet(): CancelablePromise<ModelStatusListResponse> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/models/status',
|
url: '/models/status',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Trigger Model Download
|
* Trigger Model Download
|
||||||
* Trigger download of a specific model.
|
* Trigger download of a specific model.
|
||||||
* @returns any Successful Response
|
* @returns any Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
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',
|
||||||
url: '/models/download',
|
url: '/models/download',
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
mediaType: 'application/json',
|
mediaType: 'application/json',
|
||||||
errors: {
|
errors: {
|
||||||
422: `Validation Error`,
|
422: `Validation Error`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
+2
-1
@@ -39,7 +39,7 @@ class ProfileSample(Base):
|
|||||||
class Generation(Base):
|
class Generation(Base):
|
||||||
"""Generation history database model."""
|
"""Generation history database model."""
|
||||||
__tablename__ = "generations"
|
__tablename__ = "generations"
|
||||||
|
|
||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
|
profile_id = Column(String, ForeignKey("profiles.id"), nullable=False)
|
||||||
text = Column(Text, nullable=False)
|
text = Column(Text, nullable=False)
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -28,10 +28,11 @@ 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.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
profile_id: Profile ID used for generation
|
profile_id: Profile ID used for generation
|
||||||
text: Generated text
|
text: Generated text
|
||||||
@@ -40,7 +41,8 @@ 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,13 +54,14 @@ 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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(db_generation)
|
db.add(db_generation)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_generation)
|
db.refresh(db_generation)
|
||||||
|
|
||||||
return GenerationResponse.model_validate(db_generation)
|
return GenerationResponse.model_validate(db_generation)
|
||||||
|
|
||||||
|
|
||||||
@@ -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,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -259,18 +259,19 @@ async def generate_speech(
|
|||||||
voice_prompt,
|
voice_prompt,
|
||||||
data.language,
|
data.language,
|
||||||
data.seed,
|
data.seed,
|
||||||
|
data.instruct,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate duration
|
# Calculate duration
|
||||||
duration = len(audio) / sample_rate
|
duration = len(audio) / sample_rate
|
||||||
|
|
||||||
# Save audio
|
# Save audio
|
||||||
generation_id = str(uuid.uuid4())
|
generation_id = str(uuid.uuid4())
|
||||||
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
|
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
|
||||||
|
|
||||||
from .utils.audio import save_audio
|
from .utils.audio import save_audio
|
||||||
save_audio(audio, str(audio_path), sample_rate)
|
save_audio(audio, str(audio_path), sample_rate)
|
||||||
|
|
||||||
# Create history entry
|
# Create history entry
|
||||||
generation = await history.create_generation(
|
generation = await history.create_generation(
|
||||||
profile_id=data.profile_id,
|
profile_id=data.profile_id,
|
||||||
@@ -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:
|
||||||
|
|||||||
+9
-6
@@ -241,35 +241,38 @@ 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.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Text to synthesize
|
text: Text to synthesize
|
||||||
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)
|
||||||
"""
|
"""
|
||||||
self.load_model()
|
self.load_model()
|
||||||
|
|
||||||
# Set seed if provided
|
# Set seed if provided
|
||||||
if seed is not None:
|
if seed is not None:
|
||||||
torch.manual_seed(seed)
|
torch.manual_seed(seed)
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
torch.cuda.manual_seed(seed)
|
torch.cuda.manual_seed(seed)
|
||||||
|
|
||||||
# Generate audio
|
# Generate audio
|
||||||
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
|
||||||
|
|
||||||
return audio, sample_rate
|
return audio, sample_rate
|
||||||
|
|
||||||
async def generate_from_reference(
|
async def generate_from_reference(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user