chore: formatting fixes
This commit is contained in:
parent
dd6af5e879
commit
119a46c339
167 changed files with 4507 additions and 3248 deletions
|
|
@ -7,6 +7,7 @@ out/
|
|||
.vite/
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
|
|
|||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
|
@ -37,4 +37,4 @@
|
|||
"preLaunchTask": "swift: Build Release SwiftHelper (packages/native-helpers/swift-helper)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
</picture>
|
||||
</div>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href='http://makeapullrequest.com'>
|
||||
<img alt='PRs Welcome' src='https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=shields'/>
|
||||
|
|
@ -42,6 +41,7 @@ Open source AI Dictation and Note-taking\
|
|||
Dictate hands-free, transcribe meetings, and capture notes effortlessly - powered by Gen AI
|
||||
|
||||
## ✨ Features
|
||||
|
||||
> ✔︎ - Done, ◑ - In Progress, ◯ - Planned
|
||||
|
||||
### 📱 Apps
|
||||
|
|
@ -121,8 +121,9 @@ Contributions are welcome! Please read the [Contributing Guide][contributing] to
|
|||
Released under [MIT][license].
|
||||
|
||||
<!-- REFERENCE LINKS -->
|
||||
|
||||
[contributing]: https://github.com/amicalhq/amical/blob/main/CONTRIBUTING.md
|
||||
[license]: https://github.com/amicalhq/amical/blob/main/LICENSE
|
||||
[discussions]: https://discuss.amical.ai
|
||||
[issues]: https://github.com/amicalhq/amical/issues
|
||||
[pulls]: https://github.com/amicalhq/amical/pulls "submit a pull request"
|
||||
[pulls]: https://github.com/amicalhq/amical/pulls "submit a pull request"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import type { Config } from 'drizzle-kit';
|
||||
import type { Config } from "drizzle-kit";
|
||||
|
||||
export default {
|
||||
schema: './src/db/schema.ts',
|
||||
out: './src/db/migrations',
|
||||
dialect: 'sqlite',
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./src/db/migrations",
|
||||
dialect: "sqlite",
|
||||
dbCredentials: {
|
||||
url: 'file:./amical.db',
|
||||
url: "file:./amical.db",
|
||||
},
|
||||
} satisfies Config;
|
||||
|
|
|
|||
|
|
@ -1,46 +1,53 @@
|
|||
import type { ForgeConfig } from '@electron-forge/shared-types';
|
||||
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
|
||||
import { MakerDMG } from '@electron-forge/maker-dmg';
|
||||
import { MakerDeb } from '@electron-forge/maker-deb';
|
||||
import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
import { PublisherGithub } from '@electron-forge/publisher-github';
|
||||
import { readdirSync, rmdirSync, statSync, existsSync, mkdirSync, cpSync } from 'node:fs';
|
||||
import { join, normalize } from 'node:path';
|
||||
import type { ForgeConfig } from "@electron-forge/shared-types";
|
||||
import { MakerSquirrel } from "@electron-forge/maker-squirrel";
|
||||
import { MakerDMG } from "@electron-forge/maker-dmg";
|
||||
import { MakerDeb } from "@electron-forge/maker-deb";
|
||||
import { MakerRpm } from "@electron-forge/maker-rpm";
|
||||
import { VitePlugin } from "@electron-forge/plugin-vite";
|
||||
import { FusesPlugin } from "@electron-forge/plugin-fuses";
|
||||
import { FuseV1Options, FuseVersion } from "@electron/fuses";
|
||||
import { PublisherGithub } from "@electron-forge/publisher-github";
|
||||
import {
|
||||
readdirSync,
|
||||
rmdirSync,
|
||||
statSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
cpSync,
|
||||
} from "node:fs";
|
||||
import { join, normalize } from "node:path";
|
||||
// Use flora-colossus for finding all dependencies of EXTERNAL_DEPENDENCIES
|
||||
// flora-colossus is maintained by MarshallOfSound (a top electron-forge contributor)
|
||||
// already included as a dependency of electron-packager/galactus (so we do NOT have to add it to package.json)
|
||||
// grabs nested dependencies from tree
|
||||
import { Walker, DepType, type Module } from 'flora-colossus';
|
||||
import { Walker, DepType, type Module } from "flora-colossus";
|
||||
|
||||
let nativeModuleDependenciesToPackage: string[] = [];
|
||||
|
||||
export const EXTERNAL_DEPENDENCIES = [
|
||||
'electron-squirrel-startup',
|
||||
'smart-whisper',
|
||||
'@libsql/client',
|
||||
'@libsql/darwin-arm64',
|
||||
'@libsql/darwin-x64',
|
||||
'@libsql/linux-x64-gnu',
|
||||
'@libsql/linux-x64-musl',
|
||||
'@libsql/win32-x64-msvc',
|
||||
'libsql',
|
||||
"electron-squirrel-startup",
|
||||
"smart-whisper",
|
||||
"@libsql/client",
|
||||
"@libsql/darwin-arm64",
|
||||
"@libsql/darwin-x64",
|
||||
"@libsql/linux-x64-gnu",
|
||||
"@libsql/linux-x64-musl",
|
||||
"@libsql/win32-x64-msvc",
|
||||
"libsql",
|
||||
// Add any other native modules you need here
|
||||
];
|
||||
|
||||
const config: ForgeConfig = {
|
||||
hooks: {
|
||||
prePackage: async () => {
|
||||
console.error('prePackage');
|
||||
console.error("prePackage");
|
||||
const projectRoot = normalize(__dirname);
|
||||
// In a monorepo, node_modules are typically at the root level
|
||||
const monorepoRoot = join(projectRoot, '../../'); // Go up to monorepo root
|
||||
|
||||
const monorepoRoot = join(projectRoot, "../../"); // Go up to monorepo root
|
||||
|
||||
const getExternalNestedDependencies = async (
|
||||
nodeModuleNames: string[],
|
||||
includeNestedDeps = true
|
||||
includeNestedDeps = true,
|
||||
) => {
|
||||
const foundModules = new Set(nodeModuleNames);
|
||||
if (includeNestedDeps) {
|
||||
|
|
@ -52,17 +59,21 @@ const config: ForgeConfig = {
|
|||
modules: Module[];
|
||||
walkDependenciesForModule: (
|
||||
moduleRoot: string,
|
||||
depType: DepType
|
||||
depType: DepType,
|
||||
) => Promise<void>;
|
||||
};
|
||||
const moduleRoot = join(monorepoRoot, 'node_modules', external);
|
||||
console.log('moduleRoot', moduleRoot);
|
||||
const moduleRoot = join(monorepoRoot, "node_modules", external);
|
||||
console.log("moduleRoot", moduleRoot);
|
||||
// Initialize Walker with monorepo root as base path
|
||||
const walker = new Walker(monorepoRoot) as unknown as MyPublicWalker;
|
||||
const walker = new Walker(
|
||||
monorepoRoot,
|
||||
) as unknown as MyPublicWalker;
|
||||
walker.modules = [];
|
||||
await walker.walkDependenciesForModule(moduleRoot, DepType.PROD);
|
||||
walker.modules
|
||||
.filter((dep) => (dep.nativeModuleType as number) === DepType.PROD)
|
||||
.filter(
|
||||
(dep) => (dep.nativeModuleType as number) === DepType.PROD,
|
||||
)
|
||||
// Remove the problematic name splitting that breaks scoped packages
|
||||
.map((dep) => dep.name)
|
||||
.forEach((name) => foundModules.add(name));
|
||||
|
|
@ -70,21 +81,25 @@ const config: ForgeConfig = {
|
|||
}
|
||||
return foundModules;
|
||||
};
|
||||
|
||||
const nativeModuleDependencies = await getExternalNestedDependencies(EXTERNAL_DEPENDENCIES);
|
||||
|
||||
const nativeModuleDependencies = await getExternalNestedDependencies(
|
||||
EXTERNAL_DEPENDENCIES,
|
||||
);
|
||||
nativeModuleDependenciesToPackage = Array.from(nativeModuleDependencies);
|
||||
|
||||
|
||||
// Copy external dependencies to local node_modules
|
||||
console.error('Copying external dependencies to local node_modules');
|
||||
const localNodeModules = join(projectRoot, 'node_modules');
|
||||
const rootNodeModules = join(monorepoRoot, 'node_modules');
|
||||
console.error("Copying external dependencies to local node_modules");
|
||||
const localNodeModules = join(projectRoot, "node_modules");
|
||||
const rootNodeModules = join(monorepoRoot, "node_modules");
|
||||
|
||||
// Ensure local node_modules directory exists
|
||||
if (!existsSync(localNodeModules)) {
|
||||
mkdirSync(localNodeModules, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(`Found ${nativeModuleDependenciesToPackage.length} dependencies to copy`);
|
||||
console.log(
|
||||
`Found ${nativeModuleDependenciesToPackage.length} dependencies to copy`,
|
||||
);
|
||||
|
||||
// Copy all required dependencies
|
||||
for (const dep of nativeModuleDependenciesToPackage) {
|
||||
|
|
@ -108,7 +123,6 @@ const config: ForgeConfig = {
|
|||
console.log(`Copying ${dep}...`);
|
||||
cpSync(rootDepPath, localDepPath, { recursive: true });
|
||||
console.log(`✓ Successfully copied ${dep}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy ${dep}:`, error);
|
||||
}
|
||||
|
|
@ -116,82 +130,82 @@ const config: ForgeConfig = {
|
|||
},
|
||||
packageAfterPrune: async (_forgeConfig, buildPath) => {
|
||||
try {
|
||||
function getItemsFromFolder(
|
||||
path: string,
|
||||
totalCollection: {
|
||||
path: string;
|
||||
type: 'directory' | 'file';
|
||||
empty: boolean;
|
||||
}[] = []
|
||||
) {
|
||||
try {
|
||||
const normalizedPath = normalize(path);
|
||||
const childItems = readdirSync(normalizedPath);
|
||||
const getItemStats = statSync(normalizedPath);
|
||||
if (getItemStats.isDirectory()) {
|
||||
totalCollection.push({
|
||||
path: normalizedPath,
|
||||
type: 'directory',
|
||||
empty: childItems.length === 0,
|
||||
});
|
||||
}
|
||||
childItems.forEach((childItem) => {
|
||||
const childItemNormalizedPath = join(normalizedPath, childItem);
|
||||
const childItemStats = statSync(childItemNormalizedPath);
|
||||
if (childItemStats.isDirectory()) {
|
||||
getItemsFromFolder(childItemNormalizedPath, totalCollection);
|
||||
} else {
|
||||
function getItemsFromFolder(
|
||||
path: string,
|
||||
totalCollection: {
|
||||
path: string;
|
||||
type: "directory" | "file";
|
||||
empty: boolean;
|
||||
}[] = [],
|
||||
) {
|
||||
try {
|
||||
const normalizedPath = normalize(path);
|
||||
const childItems = readdirSync(normalizedPath);
|
||||
const getItemStats = statSync(normalizedPath);
|
||||
if (getItemStats.isDirectory()) {
|
||||
totalCollection.push({
|
||||
path: childItemNormalizedPath,
|
||||
type: 'file',
|
||||
empty: false,
|
||||
path: normalizedPath,
|
||||
type: "directory",
|
||||
empty: childItems.length === 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
childItems.forEach((childItem) => {
|
||||
const childItemNormalizedPath = join(normalizedPath, childItem);
|
||||
const childItemStats = statSync(childItemNormalizedPath);
|
||||
if (childItemStats.isDirectory()) {
|
||||
getItemsFromFolder(childItemNormalizedPath, totalCollection);
|
||||
} else {
|
||||
totalCollection.push({
|
||||
path: childItemNormalizedPath,
|
||||
type: "file",
|
||||
empty: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
return totalCollection;
|
||||
}
|
||||
return totalCollection;
|
||||
}
|
||||
const getItems = getItemsFromFolder(buildPath) ?? [];
|
||||
for (const item of getItems) {
|
||||
const DELETE_EMPTY_DIRECTORIES = true;
|
||||
if (item.empty === true) {
|
||||
if (DELETE_EMPTY_DIRECTORIES) {
|
||||
const pathToDelete = normalize(item.path);
|
||||
// one last check to make sure it is a directory and is empty
|
||||
const stats = statSync(pathToDelete);
|
||||
if (!stats.isDirectory()) {
|
||||
// SKIPPING DELETION: pathToDelete is not a directory
|
||||
return;
|
||||
const getItems = getItemsFromFolder(buildPath) ?? [];
|
||||
for (const item of getItems) {
|
||||
const DELETE_EMPTY_DIRECTORIES = true;
|
||||
if (item.empty === true) {
|
||||
if (DELETE_EMPTY_DIRECTORIES) {
|
||||
const pathToDelete = normalize(item.path);
|
||||
// one last check to make sure it is a directory and is empty
|
||||
const stats = statSync(pathToDelete);
|
||||
if (!stats.isDirectory()) {
|
||||
// SKIPPING DELETION: pathToDelete is not a directory
|
||||
return;
|
||||
}
|
||||
const childItems = readdirSync(pathToDelete);
|
||||
if (childItems.length !== 0) {
|
||||
// SKIPPING DELETION: pathToDelete is not empty
|
||||
return;
|
||||
}
|
||||
rmdirSync(pathToDelete);
|
||||
}
|
||||
const childItems = readdirSync(pathToDelete);
|
||||
if (childItems.length !== 0) {
|
||||
// SKIPPING DELETION: pathToDelete is not empty
|
||||
return;
|
||||
}
|
||||
rmdirSync(pathToDelete);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in packageAfterPrune:', error);
|
||||
console.error("Error in packageAfterPrune:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
name: 'Amical',
|
||||
executableName: 'Amical',
|
||||
icon: './assets/logo', // Path to your icon file (without extension)
|
||||
name: "Amical",
|
||||
executableName: "Amical",
|
||||
icon: "./assets/logo", // Path to your icon file (without extension)
|
||||
extraResource: [
|
||||
'../../packages/native-helpers/swift-helper/bin',
|
||||
'./src/db/migrations',
|
||||
"../../packages/native-helpers/swift-helper/bin",
|
||||
"./src/db/migrations",
|
||||
],
|
||||
extendInfo: {
|
||||
NSMicrophoneUsageDescription:
|
||||
'This app needs access to your microphone to record audio for transcription.',
|
||||
"This app needs access to your microphone to record audio for transcription.",
|
||||
},
|
||||
// Code signing configuration for macOS (configure when ready to sign)
|
||||
// osxSign: {
|
||||
|
|
@ -211,19 +225,21 @@ const config: ForgeConfig = {
|
|||
prune: false,
|
||||
ignore: (file: string) => {
|
||||
try {
|
||||
|
||||
const filePath = file.toLowerCase();
|
||||
const KEEP_FILE = {
|
||||
keep: false,
|
||||
log: true,
|
||||
};
|
||||
// NOTE: must return false for empty string or nothing will be packaged
|
||||
if (filePath === '') KEEP_FILE.keep = true;
|
||||
if (!KEEP_FILE.keep && filePath === '/package.json') KEEP_FILE.keep = true;
|
||||
if (!KEEP_FILE.keep && filePath === '/node_modules') KEEP_FILE.keep = true;
|
||||
if (!KEEP_FILE.keep && filePath === '/.vite') KEEP_FILE.keep = true;
|
||||
if (!KEEP_FILE.keep && filePath.startsWith('/.vite/')) KEEP_FILE.keep = true;
|
||||
if (!KEEP_FILE.keep && filePath.startsWith('/node_modules/')) {
|
||||
if (filePath === "") KEEP_FILE.keep = true;
|
||||
if (!KEEP_FILE.keep && filePath === "/package.json")
|
||||
KEEP_FILE.keep = true;
|
||||
if (!KEEP_FILE.keep && filePath === "/node_modules")
|
||||
KEEP_FILE.keep = true;
|
||||
if (!KEEP_FILE.keep && filePath === "/.vite") KEEP_FILE.keep = true;
|
||||
if (!KEEP_FILE.keep && filePath.startsWith("/.vite/"))
|
||||
KEEP_FILE.keep = true;
|
||||
if (!KEEP_FILE.keep && filePath.startsWith("/node_modules/")) {
|
||||
// check if matches any of the external dependencies
|
||||
for (const dep of nativeModuleDependenciesToPackage) {
|
||||
if (
|
||||
|
|
@ -242,29 +258,31 @@ const config: ForgeConfig = {
|
|||
KEEP_FILE.log = false;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
// Handle scoped packages: if dep is @scope/package, also keep @scope/ directory
|
||||
if (dep.includes('/') && dep.startsWith('@')) {
|
||||
const scopeDir = dep.split('/')[0]; // @libsql/client -> @libsql
|
||||
if (dep.includes("/") && dep.startsWith("@")) {
|
||||
const scopeDir = dep.split("/")[0]; // @libsql/client -> @libsql
|
||||
if (
|
||||
filePath === `/node_modules/${scopeDir}/` ||
|
||||
filePath === `/node_modules/${scopeDir}` ||
|
||||
filePath.startsWith(`/node_modules/${scopeDir}/`)
|
||||
) {
|
||||
KEEP_FILE.keep = true;
|
||||
KEEP_FILE.log = filePath === `/node_modules/${scopeDir}/` || filePath === `/node_modules/${scopeDir}`;
|
||||
KEEP_FILE.log =
|
||||
filePath === `/node_modules/${scopeDir}/` ||
|
||||
filePath === `/node_modules/${scopeDir}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (KEEP_FILE.keep) {
|
||||
if (KEEP_FILE.log) console.log('Keeping:', file);
|
||||
if (KEEP_FILE.log) console.log("Keeping:", file);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error in ignore:', error);
|
||||
console.error("Error in ignore:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
|
@ -272,12 +290,15 @@ const config: ForgeConfig = {
|
|||
rebuildConfig: {},
|
||||
makers: [
|
||||
new MakerSquirrel({}),
|
||||
new MakerDMG({
|
||||
name: 'Amical',
|
||||
icon: './assets/logo.svg'
|
||||
}, ['darwin']),
|
||||
new MakerRpm({}),
|
||||
new MakerDeb({})
|
||||
new MakerDMG(
|
||||
{
|
||||
name: "Amical",
|
||||
icon: "./assets/logo.svg",
|
||||
},
|
||||
["darwin"],
|
||||
),
|
||||
new MakerRpm({}),
|
||||
new MakerDeb({}),
|
||||
],
|
||||
plugins: [
|
||||
new VitePlugin({
|
||||
|
|
@ -286,24 +307,24 @@ const config: ForgeConfig = {
|
|||
build: [
|
||||
{
|
||||
// `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
|
||||
entry: 'src/main/main.ts',
|
||||
config: 'vite.main.config.mts',
|
||||
target: 'main',
|
||||
entry: "src/main/main.ts",
|
||||
config: "vite.main.config.mts",
|
||||
target: "main",
|
||||
},
|
||||
{
|
||||
entry: 'src/main/preload.ts',
|
||||
config: 'vite.preload.config.mts',
|
||||
target: 'preload',
|
||||
entry: "src/main/preload.ts",
|
||||
config: "vite.preload.config.mts",
|
||||
target: "preload",
|
||||
},
|
||||
],
|
||||
renderer: [
|
||||
{
|
||||
name: 'main_window',
|
||||
config: 'vite.renderer.config.mts',
|
||||
name: "main_window",
|
||||
config: "vite.renderer.config.mts",
|
||||
},
|
||||
{
|
||||
name: 'widget_window',
|
||||
config: 'vite.widget.config.mts',
|
||||
name: "widget_window",
|
||||
config: "vite.widget.config.mts",
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
@ -322,8 +343,8 @@ const config: ForgeConfig = {
|
|||
publishers: [
|
||||
new PublisherGithub({
|
||||
repository: {
|
||||
owner: 'amicalhq',
|
||||
name: 'amical',
|
||||
owner: "amicalhq",
|
||||
name: "amical",
|
||||
},
|
||||
prerelease: true,
|
||||
draft: true, // Create draft releases first for review
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
const ShortcutIndicator: React.FC = () => {
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
|
|
@ -20,9 +20,9 @@ const ShortcutIndicator: React.FC = () => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`w-[100px] h-[100px] ${isPressed ? 'bg-red-500' : 'bg-transparent'} border-2 border-gray-300 rounded-lg transition-colors duration-100 flex items-center justify-center ${isPressed ? 'text-white' : 'text-gray-600'} text-sm`}
|
||||
className={`w-[100px] h-[100px] ${isPressed ? "bg-red-500" : "bg-transparent"} border-2 border-gray-300 rounded-lg transition-colors duration-100 flex items-center justify-center ${isPressed ? "text-white" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
{isPressed ? 'Pressed!' : 'Alt+Space'}
|
||||
{isPressed ? "Pressed!" : "Alt+Space"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface WaveformProps {
|
||||
index: number;
|
||||
|
|
@ -42,11 +42,11 @@ export function Waveform({
|
|||
}}
|
||||
transition={{
|
||||
duration: voiceDetected ? 0.8 : 0.3,
|
||||
ease: 'easeInOut',
|
||||
ease: "easeInOut",
|
||||
repeat: voiceDetected ? Number.POSITIVE_INFINITY : 0,
|
||||
repeatType: 'loop',
|
||||
repeatType: "loop",
|
||||
delay: index * 0.06,
|
||||
type: 'tween',
|
||||
type: "tween",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
import {
|
||||
IconDatabase,
|
||||
IconFileDescription,
|
||||
|
|
@ -6,10 +6,10 @@ import {
|
|||
IconReport,
|
||||
IconSettings,
|
||||
IconBookFilled,
|
||||
} from "@tabler/icons-react"
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { NavMain } from "@/components/nav-main"
|
||||
import { NavSecondary } from "@/components/nav-secondary"
|
||||
import { NavMain } from "@/components/nav-main";
|
||||
import { NavSecondary } from "@/components/nav-secondary";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
|
|
@ -18,16 +18,16 @@ import {
|
|||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
// Custom Discord icon component
|
||||
const DiscordIcon = ({ className }: { className?: string }) => (
|
||||
<img
|
||||
src="assets/discord-icon.svg"
|
||||
alt="Discord"
|
||||
className={`w-4 h-4 ${className || ''}`}
|
||||
<img
|
||||
src="assets/discord-icon.svg"
|
||||
alt="Discord"
|
||||
className={`w-4 h-4 ${className || ""}`}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
|
|
@ -88,14 +88,18 @@ const data = {
|
|||
icon: IconFileWord,
|
||||
},
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
onNavigate?: (item: { title: string }) => void;
|
||||
currentView?: string;
|
||||
}
|
||||
|
||||
export function AppSidebar({ onNavigate, currentView, ...props }: AppSidebarProps) {
|
||||
export function AppSidebar({
|
||||
onNavigate,
|
||||
currentView,
|
||||
...props
|
||||
}: AppSidebarProps) {
|
||||
return (
|
||||
<Sidebar collapsible="offcanvas" {...props}>
|
||||
<div className="h-[var(--header-height)]"></div>
|
||||
|
|
@ -106,8 +110,15 @@ export function AppSidebar({ onNavigate, currentView, ...props }: AppSidebarProp
|
|||
asChild
|
||||
className="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
>
|
||||
<a href="#" className="inline-flex items-center gap-2.5 font-semibold">
|
||||
<img src="assets/logo.svg" alt="Amical Logo" className="!size-7" />
|
||||
<a
|
||||
href="#"
|
||||
className="inline-flex items-center gap-2.5 font-semibold"
|
||||
>
|
||||
<img
|
||||
src="assets/logo.svg"
|
||||
alt="Amical Logo"
|
||||
className="!size-7"
|
||||
/>
|
||||
<span className="font-semibold">Amical</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
|
|
@ -115,12 +126,19 @@ export function AppSidebar({ onNavigate, currentView, ...props }: AppSidebarProp
|
|||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} onNavigate={onNavigate} currentView={currentView} />
|
||||
<NavSecondary items={data.navSecondary} onNavigate={onNavigate} currentView={currentView} className="mt-auto" />
|
||||
<NavMain
|
||||
items={data.navMain}
|
||||
onNavigate={onNavigate}
|
||||
currentView={currentView}
|
||||
/>
|
||||
<NavSecondary
|
||||
items={data.navSecondary}
|
||||
onNavigate={onNavigate}
|
||||
currentView={currentView}
|
||||
className="mt-auto"
|
||||
/>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
{/* <NavUser user={data.user} /> */}
|
||||
</SidebarFooter>
|
||||
<SidebarFooter>{/* <NavUser user={data.user} /> */}</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
|
|
@ -9,15 +9,15 @@ import {
|
|||
useSensors,
|
||||
type DragEndEvent,
|
||||
type UniqueIdentifier,
|
||||
} from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
|
|
@ -31,7 +31,7 @@ import {
|
|||
IconLoader,
|
||||
IconPlus,
|
||||
IconTrendingUp,
|
||||
} from "@tabler/icons-react"
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
|
|
@ -46,21 +46,21 @@ import {
|
|||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
} from "@tanstack/react-table";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
} from "@/components/ui/chart";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
|
|
@ -70,7 +70,7 @@ import {
|
|||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer"
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
|
|
@ -78,17 +78,17 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -96,13 +96,8 @@ import {
|
|||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs"
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export const schema = z.object({
|
||||
id: z.number(),
|
||||
|
|
@ -112,13 +107,13 @@ export const schema = z.object({
|
|||
target: z.string(),
|
||||
limit: z.string(),
|
||||
reviewer: z.string(),
|
||||
})
|
||||
});
|
||||
|
||||
// Create a separate component for the drag handle
|
||||
function DragHandle({ id }: { id: number }) {
|
||||
const { attributes, listeners } = useSortable({
|
||||
id,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
|
@ -131,7 +126,7 @@ function DragHandle({ id }: { id: number }) {
|
|||
<IconGripVertical className="text-muted-foreground size-3" />
|
||||
<span className="sr-only">Drag to reorder</span>
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
|
|
@ -170,7 +165,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||
accessorKey: "header",
|
||||
header: "Header",
|
||||
cell: ({ row }) => {
|
||||
return <TableCellViewer item={row.original} />
|
||||
return <TableCellViewer item={row.original} />;
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
|
|
@ -205,12 +200,12 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||
cell: ({ row }) => (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
|
||||
loading: `Saving ${row.original.header}`,
|
||||
success: "Done",
|
||||
error: "Error",
|
||||
})
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
|
||||
|
|
@ -230,12 +225,12 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||
cell: ({ row }) => (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
|
||||
loading: `Saving ${row.original.header}`,
|
||||
success: "Done",
|
||||
error: "Error",
|
||||
})
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
|
||||
|
|
@ -253,10 +248,10 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||
accessorKey: "reviewer",
|
||||
header: "Reviewer",
|
||||
cell: ({ row }) => {
|
||||
const isAssigned = row.original.reviewer !== "Assign reviewer"
|
||||
const isAssigned = row.original.reviewer !== "Assign reviewer";
|
||||
|
||||
if (isAssigned) {
|
||||
return row.original.reviewer
|
||||
return row.original.reviewer;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -280,7 +275,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -307,12 +302,12 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
|||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
|
||||
const { transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: row.original.id,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
|
|
@ -331,37 +326,37 @@ function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
|
|||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTable({
|
||||
data: initialData,
|
||||
}: {
|
||||
data: z.infer<typeof schema>[]
|
||||
data: z.infer<typeof schema>[];
|
||||
}) {
|
||||
const [data, setData] = React.useState(() => initialData)
|
||||
const [rowSelection, setRowSelection] = React.useState({})
|
||||
const [data, setData] = React.useState(() => initialData);
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({})
|
||||
React.useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
)
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
[],
|
||||
);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const sortableId = React.useId()
|
||||
});
|
||||
const sortableId = React.useId();
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
useSensor(KeyboardSensor, {}),
|
||||
);
|
||||
|
||||
const dataIds = React.useMemo<UniqueIdentifier[]>(
|
||||
() => data?.map(({ id }) => id) || [],
|
||||
[data]
|
||||
)
|
||||
[data],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
|
|
@ -386,16 +381,16 @@ export function DataTable({
|
|||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
})
|
||||
});
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
const { active, over } = event;
|
||||
if (active && over && active.id !== over.id) {
|
||||
setData((data) => {
|
||||
const oldIndex = dataIds.indexOf(active.id)
|
||||
const newIndex = dataIds.indexOf(over.id)
|
||||
return arrayMove(data, oldIndex, newIndex)
|
||||
})
|
||||
const oldIndex = dataIds.indexOf(active.id);
|
||||
const newIndex = dataIds.indexOf(over.id);
|
||||
return arrayMove(data, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -449,7 +444,7 @@ export function DataTable({
|
|||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" &&
|
||||
column.getCanHide()
|
||||
column.getCanHide(),
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
|
|
@ -463,7 +458,7 @@ export function DataTable({
|
|||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -496,10 +491,10 @@ export function DataTable({
|
|||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
@ -541,7 +536,7 @@ export function DataTable({
|
|||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
|
|
@ -622,7 +617,7 @@ export function DataTable({
|
|||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = [
|
||||
|
|
@ -632,7 +627,7 @@ const chartData = [
|
|||
{ month: "April", desktop: 73, mobile: 190 },
|
||||
{ month: "May", desktop: 209, mobile: 130 },
|
||||
{ month: "June", desktop: 214, mobile: 140 },
|
||||
]
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
desktop: {
|
||||
|
|
@ -643,10 +638,10 @@ const chartConfig = {
|
|||
label: "Mobile",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
} satisfies ChartConfig;
|
||||
|
||||
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
||||
const isMobile = useIsMobile()
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Drawer direction={isMobile ? "bottom" : "right"}>
|
||||
|
|
@ -801,5 +796,5 @@ function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
|||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,36 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { RadioGroup, RadioGroupItem } from './ui/radio-group';
|
||||
import { Label } from './ui/label';
|
||||
import { Trash2, Download, Square, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Progress } from "./ui/progress";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "./ui/tabs";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./ui/card";
|
||||
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
|
||||
import { Label } from "./ui/label";
|
||||
import { Trash2, Download, Square, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel
|
||||
} from './ui/alert-dialog';
|
||||
import { Model, DownloadedModel, DownloadProgress } from '../constants/models';
|
||||
import { api } from '@/trpc/react';
|
||||
AlertDialogCancel,
|
||||
} from "./ui/alert-dialog";
|
||||
import { Model, DownloadedModel, DownloadProgress } from "../constants/models";
|
||||
import { api } from "@/trpc/react";
|
||||
|
||||
export const ModelsView: React.FC = () => {
|
||||
const [downloadProgress, setDownloadProgress] = useState<Record<string, DownloadProgress>>({});
|
||||
const [downloadProgress, setDownloadProgress] = useState<
|
||||
Record<string, DownloadProgress>
|
||||
>({});
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [modelToDelete, setModelToDelete] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -30,7 +38,8 @@ export const ModelsView: React.FC = () => {
|
|||
const availableModelsQuery = api.models.getAvailableModels.useQuery();
|
||||
const downloadedModelsQuery = api.models.getDownloadedModels.useQuery();
|
||||
const activeDownloadsQuery = api.models.getActiveDownloads.useQuery();
|
||||
const isLocalWhisperAvailableQuery = api.models.isLocalWhisperAvailable.useQuery();
|
||||
const isLocalWhisperAvailableQuery =
|
||||
api.models.isLocalWhisperAvailable.useQuery();
|
||||
const selectedModelQuery = api.models.getSelectedModel.useQuery();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
|
@ -42,13 +51,13 @@ export const ModelsView: React.FC = () => {
|
|||
utils.models.getActiveDownloads.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to start download:', error);
|
||||
if (error instanceof Error && error.message.includes('AbortError')) {
|
||||
console.log('Download was manually aborted, not showing error');
|
||||
console.error("Failed to start download:", error);
|
||||
if (error instanceof Error && error.message.includes("AbortError")) {
|
||||
console.log("Download was manually aborted, not showing error");
|
||||
return;
|
||||
}
|
||||
toast.error('Failed to start download');
|
||||
}
|
||||
toast.error("Failed to start download");
|
||||
},
|
||||
});
|
||||
|
||||
const cancelDownloadMutation = api.models.cancelDownload.useMutation({
|
||||
|
|
@ -56,9 +65,9 @@ export const ModelsView: React.FC = () => {
|
|||
utils.models.getActiveDownloads.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to cancel download:', error);
|
||||
toast.error('Failed to cancel download');
|
||||
}
|
||||
console.error("Failed to cancel download:", error);
|
||||
toast.error("Failed to cancel download");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteModelMutation = api.models.deleteModel.useMutation({
|
||||
|
|
@ -68,11 +77,11 @@ export const ModelsView: React.FC = () => {
|
|||
setModelToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to delete model:', error);
|
||||
toast.error('Failed to delete model');
|
||||
console.error("Failed to delete model:", error);
|
||||
toast.error("Failed to delete model");
|
||||
setShowDeleteDialog(false);
|
||||
setModelToDelete(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const setSelectedModelMutation = api.models.setSelectedModel.useMutation({
|
||||
|
|
@ -80,9 +89,9 @@ export const ModelsView: React.FC = () => {
|
|||
utils.models.getSelectedModel.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to select model:', error);
|
||||
toast.error('Failed to select model');
|
||||
}
|
||||
console.error("Failed to select model:", error);
|
||||
toast.error("Failed to select model");
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize active downloads progress on load
|
||||
|
|
@ -99,16 +108,16 @@ export const ModelsView: React.FC = () => {
|
|||
// Set up tRPC subscriptions for real-time download updates
|
||||
api.models.onDownloadProgress.useSubscription(undefined, {
|
||||
onData: ({ modelId, progress }) => {
|
||||
setDownloadProgress(prev => ({ ...prev, [modelId]: progress }));
|
||||
setDownloadProgress((prev) => ({ ...prev, [modelId]: progress }));
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Download progress subscription error:', error);
|
||||
}
|
||||
console.error("Download progress subscription error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
api.models.onDownloadComplete.useSubscription(undefined, {
|
||||
onData: ({ modelId, downloadedModel }) => {
|
||||
setDownloadProgress(prev => {
|
||||
setDownloadProgress((prev) => {
|
||||
const newProgress = { ...prev };
|
||||
delete newProgress[modelId];
|
||||
return newProgress;
|
||||
|
|
@ -117,13 +126,13 @@ export const ModelsView: React.FC = () => {
|
|||
utils.models.getActiveDownloads.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Download complete subscription error:', error);
|
||||
}
|
||||
console.error("Download complete subscription error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
api.models.onDownloadError.useSubscription(undefined, {
|
||||
onData: ({ modelId, error }) => {
|
||||
setDownloadProgress(prev => {
|
||||
setDownloadProgress((prev) => {
|
||||
const newProgress = { ...prev };
|
||||
delete newProgress[modelId];
|
||||
return newProgress;
|
||||
|
|
@ -132,13 +141,13 @@ export const ModelsView: React.FC = () => {
|
|||
utils.models.getActiveDownloads.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Download error subscription error:', error);
|
||||
}
|
||||
console.error("Download error subscription error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
api.models.onDownloadCancelled.useSubscription(undefined, {
|
||||
onData: ({ modelId }) => {
|
||||
setDownloadProgress(prev => {
|
||||
setDownloadProgress((prev) => {
|
||||
const newProgress = { ...prev };
|
||||
delete newProgress[modelId];
|
||||
return newProgress;
|
||||
|
|
@ -146,8 +155,8 @@ export const ModelsView: React.FC = () => {
|
|||
utils.models.getActiveDownloads.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Download cancelled subscription error:', error);
|
||||
}
|
||||
console.error("Download cancelled subscription error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
api.models.onModelDeleted.useSubscription(undefined, {
|
||||
|
|
@ -155,8 +164,8 @@ export const ModelsView: React.FC = () => {
|
|||
utils.models.getDownloadedModels.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Model deleted subscription error:', error);
|
||||
}
|
||||
console.error("Model deleted subscription error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownload = async (modelId: string, event?: React.MouseEvent) => {
|
||||
|
|
@ -167,14 +176,17 @@ export const ModelsView: React.FC = () => {
|
|||
|
||||
try {
|
||||
await downloadModelMutation.mutateAsync({ modelId });
|
||||
console.log('Download started for:', modelId);
|
||||
console.log("Download started for:", modelId);
|
||||
} catch (err) {
|
||||
console.error('Failed to start download:', err);
|
||||
console.error("Failed to start download:", err);
|
||||
// Error is already handled by the mutation's onError
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDownload = async (modelId: string, event?: React.MouseEvent) => {
|
||||
const handleCancelDownload = async (
|
||||
modelId: string,
|
||||
event?: React.MouseEvent,
|
||||
) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
|
@ -182,9 +194,9 @@ export const ModelsView: React.FC = () => {
|
|||
|
||||
try {
|
||||
await cancelDownloadMutation.mutateAsync({ modelId });
|
||||
console.log('Cancel download successful for:', modelId);
|
||||
console.log("Cancel download successful for:", modelId);
|
||||
} catch (err) {
|
||||
console.error('Failed to cancel download:', err);
|
||||
console.error("Failed to cancel download:", err);
|
||||
// Error is already handled by the mutation's onError
|
||||
}
|
||||
};
|
||||
|
|
@ -200,7 +212,7 @@ export const ModelsView: React.FC = () => {
|
|||
try {
|
||||
await deleteModelMutation.mutateAsync({ modelId: modelToDelete });
|
||||
} catch (err) {
|
||||
console.error('Failed to delete model:', err);
|
||||
console.error("Failed to delete model:", err);
|
||||
// Error is already handled by the mutation's onError
|
||||
}
|
||||
};
|
||||
|
|
@ -214,22 +226,25 @@ export const ModelsView: React.FC = () => {
|
|||
try {
|
||||
await setSelectedModelMutation.mutateAsync({ modelId });
|
||||
} catch (err) {
|
||||
console.error('Failed to select model:', err);
|
||||
console.error("Failed to select model:", err);
|
||||
// Error is already handled by the mutation's onError
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
// Loading state
|
||||
const loading = availableModelsQuery.isLoading || downloadedModelsQuery.isLoading ||
|
||||
isLocalWhisperAvailableQuery.isLoading || selectedModelQuery.isLoading;
|
||||
const loading =
|
||||
availableModelsQuery.isLoading ||
|
||||
downloadedModelsQuery.isLoading ||
|
||||
isLocalWhisperAvailableQuery.isLoading ||
|
||||
selectedModelQuery.isLoading;
|
||||
|
||||
// Data from queries
|
||||
const availableModels = availableModelsQuery.data || [];
|
||||
|
|
@ -250,7 +265,9 @@ export const ModelsView: React.FC = () => {
|
|||
<div className="h-full p-6">
|
||||
<Tabs defaultValue="speech-recognition" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-1">
|
||||
<TabsTrigger value="speech-recognition">Speech Recognition</TabsTrigger>
|
||||
<TabsTrigger value="speech-recognition">
|
||||
Speech Recognition
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="speech-recognition" className="space-y-6 mt-6">
|
||||
|
|
@ -263,17 +280,20 @@ export const ModelsView: React.FC = () => {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup
|
||||
value={selectedModel || ''}
|
||||
value={selectedModel || ""}
|
||||
onValueChange={handleSelectModel}
|
||||
className="space-y-4"
|
||||
>
|
||||
{availableModels.map((model) => {
|
||||
const isDownloaded = !!downloadedModels[model.id];
|
||||
const progress = downloadProgress[model.id];
|
||||
const isDownloading = progress?.status === 'downloading';
|
||||
const isDownloading = progress?.status === "downloading";
|
||||
|
||||
return (
|
||||
<div key={model.id} className="flex items-center justify-between py-3 border-b last:border-b-0">
|
||||
<div
|
||||
key={model.id}
|
||||
className="flex items-center justify-between py-3 border-b last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<RadioGroupItem
|
||||
value={model.id}
|
||||
|
|
@ -281,7 +301,10 @@ export const ModelsView: React.FC = () => {
|
|||
disabled={!isDownloaded || !isLocalWhisperAvailable}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor={model.id} className="text-base font-medium cursor-pointer">
|
||||
<Label
|
||||
htmlFor={model.id}
|
||||
className="text-base font-medium cursor-pointer"
|
||||
>
|
||||
{model.name}
|
||||
</Label>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
|
|
@ -310,7 +333,7 @@ export const ModelsView: React.FC = () => {
|
|||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
|
||||
{/* Circular Progress Ring */}
|
||||
{progress && (
|
||||
<svg
|
||||
|
|
@ -352,7 +375,7 @@ export const ModelsView: React.FC = () => {
|
|||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{model.sizeFormatted}
|
||||
</div>
|
||||
|
|
@ -364,7 +387,6 @@ export const ModelsView: React.FC = () => {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
|
|
@ -372,12 +394,19 @@ export const ModelsView: React.FC = () => {
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Model</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this model? This action cannot be undone and you will need to download the model again if you want to use it.
|
||||
Are you sure you want to delete this model? This action cannot be
|
||||
undone and you will need to download the model again if you want
|
||||
to use it.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleDeleteCancel}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm} className="bg-red-500 hover:bg-red-600">
|
||||
<AlertDialogCancel onClick={handleDeleteCancel}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
@ -385,4 +414,4 @@ export const ModelsView: React.FC = () => {
|
|||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
|
||||
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react";
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
|
|
@ -15,12 +15,12 @@ export function NavMain({
|
|||
currentView,
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon?: Icon
|
||||
}[]
|
||||
onNavigate?: (item: { title: string }) => void
|
||||
currentView?: string
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: Icon;
|
||||
}[];
|
||||
onNavigate?: (item: { title: string }) => void;
|
||||
currentView?: string;
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
|
|
@ -28,7 +28,7 @@ export function NavMain({
|
|||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={currentView === item.title}
|
||||
onClick={() => onNavigate?.(item)}
|
||||
|
|
@ -41,5 +41,5 @@ export function NavMain({
|
|||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { type Icon } from "@tabler/icons-react"
|
||||
import * as React from "react";
|
||||
import { type Icon } from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavSecondary({
|
||||
items,
|
||||
|
|
@ -18,13 +18,13 @@ export function NavSecondary({
|
|||
...props
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon: Icon
|
||||
external?: boolean
|
||||
}[]
|
||||
onNavigate?: (item: { title: string }) => void
|
||||
currentView?: string
|
||||
title: string;
|
||||
url: string;
|
||||
icon: Icon;
|
||||
external?: boolean;
|
||||
}[];
|
||||
onNavigate?: (item: { title: string }) => void;
|
||||
currentView?: string;
|
||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||
return (
|
||||
<SidebarGroup {...props}>
|
||||
|
|
@ -32,7 +32,7 @@ export function NavSecondary({
|
|||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
<SidebarMenuButton
|
||||
isActive={currentView === item.title}
|
||||
onClick={() => onNavigate?.(item)}
|
||||
>
|
||||
|
|
@ -44,5 +44,5 @@ export function NavSecondary({
|
|||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,62 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { FormatterConfig } from '@/modules/formatter';
|
||||
import { api } from '@/trpc/react';
|
||||
import { toast } from 'sonner';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { FormatterConfig } from "@/modules/formatter";
|
||||
import { api } from "@/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// OpenRouter models list
|
||||
const OPENROUTER_MODELS = [
|
||||
{ value: 'anthropic/claude-3.5-sonnet', label: 'Claude 3.5 Sonnet' },
|
||||
{ value: 'anthropic/claude-3-haiku', label: 'Claude 3 Haiku' },
|
||||
{ value: 'openai/gpt-4o', label: 'GPT-4o' },
|
||||
{ value: 'openai/gpt-4o-mini', label: 'GPT-4o mini' },
|
||||
{ value: 'openai/gpt-4-turbo', label: 'GPT-4 Turbo' },
|
||||
{ value: 'meta-llama/llama-3.1-8b-instruct', label: 'Llama 3.1 8B' },
|
||||
{ value: 'meta-llama/llama-3.1-70b-instruct', label: 'Llama 3.1 70B' },
|
||||
{ value: 'google/gemini-pro-1.5', label: 'Gemini Pro 1.5' },
|
||||
{ value: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet" },
|
||||
{ value: "anthropic/claude-3-haiku", label: "Claude 3 Haiku" },
|
||||
{ value: "openai/gpt-4o", label: "GPT-4o" },
|
||||
{ value: "openai/gpt-4o-mini", label: "GPT-4o mini" },
|
||||
{ value: "openai/gpt-4-turbo", label: "GPT-4 Turbo" },
|
||||
{ value: "meta-llama/llama-3.1-8b-instruct", label: "Llama 3.1 8B" },
|
||||
{ value: "meta-llama/llama-3.1-70b-instruct", label: "Llama 3.1 70B" },
|
||||
{ value: "google/gemini-pro-1.5", label: "Gemini Pro 1.5" },
|
||||
];
|
||||
|
||||
export function SettingsView() {
|
||||
const [formatterProvider, setFormatterProvider] = useState<'openrouter'>('openrouter');
|
||||
const [openrouterModel, setOpenrouterModel] = useState('');
|
||||
const [openrouterApiKey, setOpenrouterApiKey] = useState('');
|
||||
const [formatterProvider, setFormatterProvider] =
|
||||
useState<"openrouter">("openrouter");
|
||||
const [openrouterModel, setOpenrouterModel] = useState("");
|
||||
const [openrouterApiKey, setOpenrouterApiKey] = useState("");
|
||||
const [formatterEnabled, setFormatterEnabled] = useState(false);
|
||||
|
||||
|
||||
|
||||
// tRPC queries and mutations
|
||||
const formatterConfigQuery = api.settings.getFormatterConfig.useQuery();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const setFormatterConfigMutation = api.settings.setFormatterConfig.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Configuration saved successfully!');
|
||||
utils.settings.getFormatterConfig.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to save formatter config:', error);
|
||||
toast.error('Failed to save configuration. Please try again.');
|
||||
}
|
||||
});
|
||||
const setFormatterConfigMutation =
|
||||
api.settings.setFormatterConfig.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Configuration saved successfully!");
|
||||
utils.settings.getFormatterConfig.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to save formatter config:", error);
|
||||
toast.error("Failed to save configuration. Please try again.");
|
||||
},
|
||||
});
|
||||
|
||||
// Load configuration when query data is available
|
||||
useEffect(() => {
|
||||
|
|
@ -68,8 +80,6 @@ export function SettingsView() {
|
|||
setFormatterConfigMutation.mutate(config);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
|
|
@ -80,61 +90,81 @@ export function SettingsView() {
|
|||
<TabsTrigger value="formatter">Formatter</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
<TabsContent value="general" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>General Settings</CardTitle>
|
||||
<CardDescription>Configure your general preferences</CardDescription>
|
||||
<CardDescription>
|
||||
Configure your general preferences
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="launch-login">Launch at Login</Label>
|
||||
<p className="text-sm text-muted-foreground">Start Amical when you log in</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Start Amical when you log in
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="launch-login" />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="minimize-tray">Minimize to Tray</Label>
|
||||
<p className="text-sm text-muted-foreground">Keep running in system tray when closed</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keep running in system tray when closed
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="minimize-tray" />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="theme-toggle">Theme</Label>
|
||||
<p className="text-sm text-muted-foreground">Choose your preferred theme</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose your preferred theme
|
||||
</p>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="microphone" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Microphone Settings</CardTitle>
|
||||
<CardDescription>Configure your microphone preferences</CardDescription>
|
||||
<CardDescription>
|
||||
Configure your microphone preferences
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="microphone-select">Microphone</Label>
|
||||
<select id="microphone-select" className="w-full border rounded px-3 py-2">
|
||||
<select
|
||||
id="microphone-select"
|
||||
className="w-full border rounded px-3 py-2"
|
||||
>
|
||||
<option>System Default</option>
|
||||
<option>Built-in Microphone</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="input-volume">Input Volume</Label>
|
||||
<input type="range" id="input-volume" className="w-full" min="0" max="100" defaultValue="75" />
|
||||
<input
|
||||
type="range"
|
||||
id="input-volume"
|
||||
className="w-full"
|
||||
min="0"
|
||||
max="100"
|
||||
defaultValue="75"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="noise-reduction" />
|
||||
<Label htmlFor="noise-reduction">Enable noise reduction</Label>
|
||||
|
|
@ -142,45 +172,62 @@ export function SettingsView() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="shortcuts" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Keyboard Shortcuts</CardTitle>
|
||||
<CardDescription>Customize your keyboard shortcuts</CardDescription>
|
||||
<CardDescription>
|
||||
Customize your keyboard shortcuts
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Global Shortcut</Label>
|
||||
<p className="text-sm text-muted-foreground">Start/stop recording</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Start/stop recording
|
||||
</p>
|
||||
</div>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm">Ctrl+Shift+Space</kbd>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm">
|
||||
Ctrl+Shift+Space
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Toggle Window</Label>
|
||||
<p className="text-sm text-muted-foreground">Show/hide main window</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Show/hide main window
|
||||
</p>
|
||||
</div>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm">Ctrl+Shift+A</kbd>
|
||||
<kbd className="px-2 py-1 bg-muted rounded text-sm">
|
||||
Ctrl+Shift+A
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
|
||||
<Button variant="outline">Customize Shortcuts</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
<TabsContent value="formatter" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Text Formatting Configuration</CardTitle>
|
||||
<CardDescription>Configure AI-powered post-processing of transcriptions</CardDescription>
|
||||
<CardDescription>
|
||||
Configure AI-powered post-processing of transcriptions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="formatter-provider">Provider</Label>
|
||||
<Select value={formatterProvider} onValueChange={(value: 'openrouter') => setFormatterProvider(value)}>
|
||||
<Select
|
||||
value={formatterProvider}
|
||||
onValueChange={(value: "openrouter") =>
|
||||
setFormatterProvider(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -189,12 +236,15 @@ export function SettingsView() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formatterProvider === 'openrouter' && (
|
||||
|
||||
{formatterProvider === "openrouter" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openrouter-model">Model</Label>
|
||||
<Select value={openrouterModel} onValueChange={setOpenrouterModel}>
|
||||
<Select
|
||||
value={openrouterModel}
|
||||
onValueChange={setOpenrouterModel}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -207,7 +257,7 @@ export function SettingsView() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openrouter-api-key">API Key</Label>
|
||||
<Input
|
||||
|
|
@ -218,38 +268,52 @@ export function SettingsView() {
|
|||
onChange={(e) => setOpenrouterApiKey(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get your API key from <a href="https://openrouter.ai" target="_blank" rel="noopener noreferrer" className="underline">openrouter.ai</a>
|
||||
Get your API key from{" "}
|
||||
<a
|
||||
href="https://openrouter.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
openrouter.ai
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="enable-formatter">Enable Formatter</Label>
|
||||
<p className="text-sm text-muted-foreground">Apply AI formatting to transcriptions</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Apply AI formatting to transcriptions
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-formatter"
|
||||
<Switch
|
||||
id="enable-formatter"
|
||||
checked={formatterEnabled}
|
||||
onCheckedChange={setFormatterEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
<Button
|
||||
onClick={saveFormatterConfig}
|
||||
disabled={setFormatterConfigMutation.isPending || !openrouterModel || !openrouterApiKey}
|
||||
disabled={
|
||||
setFormatterConfigMutation.isPending ||
|
||||
!openrouterModel ||
|
||||
!openrouterApiKey
|
||||
}
|
||||
>
|
||||
{setFormatterConfigMutation.isPending ? 'Saving...' : 'Save Configuration'}
|
||||
{setFormatterConfigMutation.isPending
|
||||
? "Saving..."
|
||||
: "Save Configuration"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
|
||||
<TabsContent value="advanced" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -260,25 +324,29 @@ export function SettingsView() {
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="debug-mode">Debug Mode</Label>
|
||||
<p className="text-sm text-muted-foreground">Enable detailed logging</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enable detailed logging
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="debug-mode" />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="auto-update">Auto Updates</Label>
|
||||
<p className="text-sm text-muted-foreground">Automatically check for updates</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically check for updates
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="auto-update" defaultChecked />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-location">Data Location</Label>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
id="data-location"
|
||||
<input
|
||||
type="text"
|
||||
id="data-location"
|
||||
className="flex-1 border rounded px-3 py-2"
|
||||
value="~/Documents/Amical"
|
||||
readOnly
|
||||
|
|
@ -292,4 +360,4 @@ export function SettingsView() {
|
|||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
|
||||
interface SiteHeaderProps {
|
||||
currentView?: string;
|
||||
|
|
@ -8,26 +8,26 @@ interface SiteHeaderProps {
|
|||
|
||||
export function SiteHeader({ currentView }: SiteHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
<header
|
||||
className="flex h-[var(--header-height)] shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 w-full"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<div className="flex w-full items-center gap-1">
|
||||
{/* macOS traffic light button spacing */}
|
||||
<div className="w-[78px] flex-shrink-0" />
|
||||
|
||||
|
||||
<div className="flex items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||
<SidebarTrigger
|
||||
className="-ml-1"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
<SidebarTrigger
|
||||
className="-ml-1"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
/>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<h1 className="text-base font-medium">{currentView || 'Amical'}</h1>
|
||||
<h1 className="text-base font-medium">{currentView || "Amical"}</h1>
|
||||
</div>
|
||||
|
||||
|
||||
{/* <div className="ml-auto flex items-center gap-2 px-4 lg:px-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -48,5 +48,5 @@ export function SiteHeader({ currentView }: SiteHeaderProps) {
|
|||
</div> */}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
type ThemeProviderProps = React.ComponentProps<typeof NextThemesProvider>
|
||||
type ThemeProviderProps = React.ComponentProps<typeof NextThemesProvider>;
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
|
|
@ -14,5 +14,5 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
|||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import * as React from "react"
|
||||
import { Moon, Sun, Monitor } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import * as React from "react";
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme, theme } = useTheme()
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
|
@ -37,21 +37,21 @@ export function ThemeToggle() {
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeToggleSimple() {
|
||||
const { setTheme, theme } = useTheme()
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === "light") {
|
||||
setTheme("dark")
|
||||
setTheme("dark");
|
||||
} else if (theme === "dark") {
|
||||
setTheme("system")
|
||||
setTheme("system");
|
||||
} else {
|
||||
setTheme("light")
|
||||
setTheme("light");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="icon" onClick={toggleTheme}>
|
||||
|
|
@ -59,5 +59,5 @@ export function ThemeToggleSimple() {
|
|||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,28 @@
|
|||
import React, { useState } from 'react';
|
||||
import type { Transcription } from '@/db/schema';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { api } from '@/trpc/react';
|
||||
import React, { useState } from "react";
|
||||
import type { Transcription } from "@/db/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { api } from "@/trpc/react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Copy, Play, Trash2, Download, FileText, Search, Filter, MoreHorizontal } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { Input } from '@/components/ui/input';
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Copy,
|
||||
Play,
|
||||
Trash2,
|
||||
Download,
|
||||
FileText,
|
||||
Search,
|
||||
Filter,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -21,47 +30,50 @@ import {
|
|||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export const TranscriptionsList: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// tRPC React Query hooks
|
||||
const transcriptionsQuery = api.transcriptions.getTranscriptions.useQuery({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
sortBy: 'timestamp',
|
||||
sortOrder: 'desc',
|
||||
sortBy: "timestamp",
|
||||
sortOrder: "desc",
|
||||
search: searchTerm || undefined,
|
||||
});
|
||||
|
||||
const transcriptionsCountQuery = api.transcriptions.getTranscriptionsCount.useQuery({
|
||||
search: searchTerm || undefined,
|
||||
});
|
||||
const transcriptionsCountQuery =
|
||||
api.transcriptions.getTranscriptionsCount.useQuery({
|
||||
search: searchTerm || undefined,
|
||||
});
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteTranscriptionMutation = api.transcriptions.deleteTranscription.useMutation({
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch transcriptions data
|
||||
utils.transcriptions.getTranscriptions.invalidate();
|
||||
utils.transcriptions.getTranscriptionsCount.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error deleting transcription:', error);
|
||||
}
|
||||
});
|
||||
const deleteTranscriptionMutation =
|
||||
api.transcriptions.deleteTranscription.useMutation({
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch transcriptions data
|
||||
utils.transcriptions.getTranscriptions.invalidate();
|
||||
utils.transcriptions.getTranscriptionsCount.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error deleting transcription:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const transcriptions = transcriptionsQuery.data || [];
|
||||
const totalCount = transcriptionsCountQuery.data || 0;
|
||||
const loading = transcriptionsQuery.isLoading || transcriptionsCountQuery.isLoading;
|
||||
const loading =
|
||||
transcriptionsQuery.isLoading || transcriptionsCountQuery.isLoading;
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
console.log('Copied to clipboard');
|
||||
console.log("Copied to clipboard");
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
console.error("Failed to copy text: ", err);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -71,13 +83,13 @@ export const TranscriptionsList: React.FC = () => {
|
|||
|
||||
const handlePlayAudio = (audioFile: string) => {
|
||||
// Implement audio playback functionality
|
||||
console.log('Playing audio:', audioFile);
|
||||
console.log("Playing audio:", audioFile);
|
||||
};
|
||||
|
||||
const handleDownload = (transcription: Transcription) => {
|
||||
// Create and download a text file with the transcription
|
||||
const element = document.createElement('a');
|
||||
const file = new Blob([transcription.text], { type: 'text/plain' });
|
||||
const element = document.createElement("a");
|
||||
const file = new Blob([transcription.text], { type: "text/plain" });
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = `transcription-${transcription.id}.txt`;
|
||||
document.body.appendChild(element);
|
||||
|
|
@ -86,12 +98,14 @@ export const TranscriptionsList: React.FC = () => {
|
|||
};
|
||||
|
||||
const getTitle = (text: string) => {
|
||||
const firstSentence = text.split('.')[0];
|
||||
return firstSentence.length > 50 ? firstSentence.substring(0, 50) + '...' : firstSentence;
|
||||
const firstSentence = text.split(".")[0];
|
||||
return firstSentence.length > 50
|
||||
? firstSentence.substring(0, 50) + "..."
|
||||
: firstSentence;
|
||||
};
|
||||
|
||||
const getWordCount = (text: string) => {
|
||||
return text.split(' ').length;
|
||||
return text.split(" ").length;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -127,7 +141,9 @@ export const TranscriptionsList: React.FC = () => {
|
|||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center space-y-2 text-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading transcriptions...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading transcriptions...
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -138,18 +154,21 @@ export const TranscriptionsList: React.FC = () => {
|
|||
<FileText className="h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium">No transcriptions found</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
{searchTerm ? 'Try adjusting your search terms.' : 'Start recording to see your transcriptions here.'}
|
||||
{searchTerm
|
||||
? "Try adjusting your search terms."
|
||||
: "Start recording to see your transcriptions here."}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<Button className="mt-4">Start Recording</Button>
|
||||
)}
|
||||
{!searchTerm && <Button className="mt-4">Start Recording</Button>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{transcriptions.map((transcription) => (
|
||||
<Card key={transcription.id} className="hover:shadow-md transition-shadow">
|
||||
<Card
|
||||
key={transcription.id}
|
||||
className="hover:shadow-md transition-shadow"
|
||||
>
|
||||
<CardContent className="px-4 py-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
|
|
@ -161,15 +180,19 @@ export const TranscriptionsList: React.FC = () => {
|
|||
<Badge variant="secondary" className="text-xs">
|
||||
{getWordCount(transcription.text)} words
|
||||
</Badge>
|
||||
<span>{format(new Date(transcription.timestamp), 'MMM d')}</span>
|
||||
<span>{format(new Date(transcription.timestamp), 'h:mm a')}</span>
|
||||
<span>
|
||||
{format(new Date(transcription.timestamp), "MMM d")}
|
||||
</span>
|
||||
<span>
|
||||
{format(new Date(transcription.timestamp), "h:mm a")}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{transcription.language?.toUpperCase() || 'EN'}
|
||||
{transcription.language?.toUpperCase() || "EN"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
|
@ -195,7 +218,9 @@ export const TranscriptionsList: React.FC = () => {
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handlePlayAudio(transcription.audioFile!)}
|
||||
onClick={() =>
|
||||
handlePlayAudio(transcription.audioFile!)
|
||||
}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -207,18 +232,24 @@ export const TranscriptionsList: React.FC = () => {
|
|||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => handleDownload(transcription)}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDownload(transcription)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(transcription.id)}
|
||||
className="text-destructive"
|
||||
disabled={deleteTranscriptionMutation.isPending}
|
||||
|
|
@ -239,13 +270,16 @@ export const TranscriptionsList: React.FC = () => {
|
|||
{!loading && transcriptions.length > 0 && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
Showing {transcriptions.length} of {totalCount} transcription{totalCount !== 1 ? 's' : ''}
|
||||
Showing {transcriptions.length} of {totalCount} transcription
|
||||
{totalCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span>
|
||||
Total: {transcriptions.reduce((acc, t) => acc + getWordCount(t.text), 0)} words
|
||||
Total:{" "}
|
||||
{transcriptions.reduce((acc, t) => acc + getWordCount(t.text), 0)}{" "}
|
||||
words
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { TranscriptionsList } from './transcriptions-list';
|
||||
import React from "react";
|
||||
import { TranscriptionsList } from "./transcriptions-list";
|
||||
|
||||
export function TranscriptionsView() {
|
||||
return <TranscriptionsList />;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import * as React from 'react';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
|
|
@ -15,7 +17,7 @@ function AccordionItem({
|
|||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn('border-b last:border-b-0', className)}
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -31,8 +33,8 @@ function AccordionTrigger({
|
|||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||
className
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -54,7 +56,7 @@ function AccordionContent({
|
|||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pt-0 pb-4', className)}>{children}</div>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,31 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
|
|
@ -28,8 +36,8 @@ function AlertDialogOverlay({
|
|||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -46,8 +54,8 @@ function AlertDialogContent({
|
|||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
'bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className
|
||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -55,21 +63,30 @@ function AlertDialogContent({
|
|||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -82,7 +99,7 @@ function AlertDialogTitle({
|
|||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -95,7 +112,7 @@ function AlertDialogDescription({
|
|||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -105,7 +122,12 @@ function AlertDialogAction({
|
|||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />;
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
|
|
@ -114,7 +136,7 @@ function AlertDialogCancel({
|
|||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
|
|
@ -34,23 +34,29 @@ function Alert({
|
|||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||
className
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||
|
||||
function AspectRatio({ ...props }: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,34 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn('aspect-square size-full', className)}
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -32,7 +41,10 @@ function AvatarFallback({
|
|||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,26 +1,28 @@
|
|||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
|
|
@ -28,11 +30,16 @@ function Badge({
|
|||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span';
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,31 +1,31 @@
|
|||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
|
||||
className
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn('inline-flex items-center gap-1.5', className)}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -35,40 +35,44 @@ function BreadcrumbLink({
|
|||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & {
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn('hover:text-foreground transition-colors', className)}
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('text-foreground font-normal', className)}
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) {
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
|
|
@ -76,13 +80,16 @@ function BreadcrumbSeparator({ children, className, ...props }: React.ComponentP
|
|||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex size-9 items-center justify-center', className)}
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
|
|
|
|||
|
|
@ -1,35 +1,38 @@
|
|||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
|
|
@ -38,11 +41,11 @@ function Button({
|
|||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
|
|
@ -14,52 +14,55 @@ function Calendar({
|
|||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn('p-3', className)}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: 'flex flex-col sm:flex-row gap-2',
|
||||
month: 'flex flex-col gap-4',
|
||||
caption: 'flex justify-center pt-1 relative items-center w-full',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'flex items-center gap-1',
|
||||
months: "flex flex-col sm:flex-row gap-2",
|
||||
month: "flex flex-col gap-4",
|
||||
caption: "flex justify-center pt-1 relative items-center w-full",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "flex items-center gap-1",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100'
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
),
|
||||
nav_button_previous: 'absolute left-1',
|
||||
nav_button_next: 'absolute right-1',
|
||||
table: 'w-full border-collapse space-x-1',
|
||||
head_row: 'flex',
|
||||
head_cell: 'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
|
||||
row: 'flex w-full mt-2',
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-x-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: cn(
|
||||
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md',
|
||||
props.mode === 'range'
|
||||
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
|
||||
: '[&:has([aria-selected])]:rounded-md'
|
||||
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||
props.mode === "range"
|
||||
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||
: "[&:has([aria-selected])]:rounded-md",
|
||||
),
|
||||
day: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'size-8 p-0 font-normal aria-selected:opacity-100'
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"size-8 p-0 font-normal aria-selected:opacity-100",
|
||||
),
|
||||
day_range_start:
|
||||
'day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground',
|
||||
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||
day_range_end:
|
||||
'day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground',
|
||||
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||
day_selected:
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||
day_today: 'bg-accent text-accent-foreground',
|
||||
day_outside: 'day-outside text-muted-foreground aria-selected:text-muted-foreground',
|
||||
day_disabled: 'text-muted-foreground opacity-50',
|
||||
day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
day_hidden: 'invisible',
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ className, ...props }) => (
|
||||
<ChevronLeft className={cn('size-4', className)} {...props} />
|
||||
<ChevronLeft className={cn("size-4", className)} {...props} />
|
||||
),
|
||||
IconRight: ({ className, ...props }) => (
|
||||
<ChevronRight className={cn('size-4', className)} {...props} />
|
||||
<ChevronRight className={cn("size-4", className)} {...props} />
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,75 +1,92 @@
|
|||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
className
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react';
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
|
|
@ -15,7 +17,7 @@ type CarouselPlugin = UseCarouselParameters[1];
|
|||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
|
|
@ -34,27 +36,27 @@ function useCarousel() {
|
|||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCarousel must be used within a <Carousel />');
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = 'horizontal',
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & CarouselProps) {
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === 'horizontal' ? 'x' : 'y',
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
|
@ -75,15 +77,15 @@ function Carousel({
|
|||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -94,11 +96,11 @@ function Carousel({
|
|||
React.useEffect(() => {
|
||||
if (!api) return;
|
||||
onSelect(api);
|
||||
api.on('reInit', onSelect);
|
||||
api.on('select', onSelect);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off('select', onSelect);
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
|
|
@ -108,7 +110,8 @@ function Carousel({
|
|||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation: orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
|
|
@ -117,7 +120,7 @@ function Carousel({
|
|||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn('relative', className)}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
|
|
@ -129,20 +132,28 @@ function Carousel({
|
|||
);
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden" data-slot="carousel-content">
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn('flex', orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col', className)}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
|
|
@ -151,9 +162,9 @@ function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
|
|||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
'min-w-0 shrink-0 grow-0 basis-full',
|
||||
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
||||
className
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -162,8 +173,8 @@ function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
|
|||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = 'outline',
|
||||
size = 'icon',
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
|
@ -174,11 +185,11 @@ function CarouselPrevious({
|
|||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute size-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? 'top-1/2 -left-12 -translate-y-1/2'
|
||||
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
|
|
@ -192,8 +203,8 @@ function CarouselPrevious({
|
|||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = 'outline',
|
||||
size = 'icon',
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
|
@ -204,11 +215,11 @@ function CarouselNext({
|
|||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute size-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? 'top-1/2 -right-12 -translate-y-1/2'
|
||||
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import * as React from 'react';
|
||||
import * as RechartsPrimitive from 'recharts';
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: '', dark: '.dark' } as const;
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
|
|
@ -26,7 +26,7 @@ function useChart() {
|
|||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useChart must be used within a <ChartContainer />');
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
|
|
@ -38,12 +38,14 @@ function ChartContainer({
|
|||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
|
|
@ -52,19 +54,23 @@ function ChartContainer({
|
|||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
|
|
@ -79,14 +85,16 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
|||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join('\n')}
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
`,
|
||||
)
|
||||
.join('\n'),
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
@ -98,7 +106,7 @@ function ChartTooltipContent({
|
|||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = 'dot',
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
|
|
@ -109,10 +117,10 @@ function ChartTooltipContent({
|
|||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<'div'> & {
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: 'line' | 'dot' | 'dashed';
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}) {
|
||||
|
|
@ -124,16 +132,18 @@ function ChartTooltipContent({
|
|||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -141,26 +151,34 @@ function ChartTooltipContent({
|
|||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||
className
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
|
|
@ -168,8 +186,8 @@ function ChartTooltipContent({
|
|||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
|
||||
indicator === 'dot' && 'items-center'
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
|
|
@ -182,19 +200,19 @@ function ChartTooltipContent({
|
|||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
'h-2.5 w-2.5': indicator === 'dot',
|
||||
'w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||
indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed',
|
||||
}
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--color-bg': indicatorColor,
|
||||
'--color-border': indicatorColor,
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
|
|
@ -202,8 +220,8 @@ function ChartTooltipContent({
|
|||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 justify-between leading-none',
|
||||
nestLabel ? 'items-end' : 'items-center'
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
|
|
@ -234,10 +252,10 @@ function ChartLegendContent({
|
|||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = 'bottom',
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<'div'> &
|
||||
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}) {
|
||||
|
|
@ -250,20 +268,20 @@ function ChartLegendContent({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-4',
|
||||
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||
className
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || 'value'}`;
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3'
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
|
|
@ -285,29 +303,42 @@ function ChartLegendContent({
|
|||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,31 @@
|
|||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
|
|
|||
|
|
@ -1,25 +1,28 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
className
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -27,8 +30,8 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
|
|||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = 'Command Palette',
|
||||
description = 'Search for a command to run...',
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
|
|
@ -55,13 +58,16 @@ function CommandInput({
|
|||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -69,17 +75,25 @@ function CommandInput({
|
|||
);
|
||||
}
|
||||
|
||||
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
|
|
@ -97,8 +111,8 @@ function CommandGroup({
|
|||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
|
||||
className
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -112,30 +126,39 @@ function CommandSeparator({
|
|||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn('bg-border -mx-1 h-px', className)}
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,37 +1,56 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />;
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
|
|
@ -48,7 +67,7 @@ function ContextMenuSubTrigger({
|
|||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -66,8 +85,8 @@ function ContextMenuSubContent({
|
|||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className
|
||||
"bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -83,8 +102,8 @@ function ContextMenuContent({
|
|||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground 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 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className
|
||||
"bg-popover text-popover-foreground 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 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -95,11 +114,11 @@ function ContextMenuContent({
|
|||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
|
|
@ -108,7 +127,7 @@ function ContextMenuItem({
|
|||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -126,7 +145,7 @@ function ContextMenuCheckboxItem({
|
|||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
|
|
@ -151,7 +170,7 @@ function ContextMenuRadioItem({
|
|||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -176,7 +195,10 @@ function ContextMenuLabel({
|
|||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn('text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||
className={cn(
|
||||
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -189,17 +211,23 @@ function ContextMenuSeparator({
|
|||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,22 +1,30 @@
|
|||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
|
|
@ -28,8 +36,8 @@ function DialogOverlay({
|
|||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -47,8 +55,8 @@ function DialogContent({
|
|||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className
|
||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -62,31 +70,37 @@ function DialogContent({
|
|||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn('text-lg leading-none font-semibold', className)}
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -99,7 +113,7 @@ function DialogDescription({
|
|||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,21 +1,29 @@
|
|||
import * as React from 'react';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul';
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Drawer({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
|
|
@ -27,8 +35,8 @@ function DrawerOverlay({
|
|||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -46,12 +54,12 @@ function DrawerContent({
|
|||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
|
||||
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
|
||||
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
|
||||
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
|
||||
className
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -62,31 +70,34 @@ function DrawerContent({
|
|||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -99,7 +110,7 @@ function DrawerDescription({
|
|||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,25 +1,34 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
|
|
@ -33,8 +42,8 @@ function DropdownMenuContent({
|
|||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className
|
||||
"bg-popover text-popover-foreground 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -42,18 +51,22 @@ function DropdownMenuContent({
|
|||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
|
|
@ -62,7 +75,7 @@ function DropdownMenuItem({
|
|||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -80,7 +93,7 @@ function DropdownMenuCheckboxItem({
|
|||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
|
|
@ -98,7 +111,12 @@ function DropdownMenuCheckboxItem({
|
|||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
|
|
@ -111,7 +129,7 @@ function DropdownMenuRadioItem({
|
|||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -136,7 +154,10 @@ function DropdownMenuLabel({
|
|||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -149,23 +170,31 @@ function DropdownMenuSeparator({
|
|||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
|
|
@ -182,8 +211,8 @@ function DropdownMenuSubTrigger({
|
|||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
className
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -201,8 +230,8 @@ function DropdownMenuSubContent({
|
|||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className
|
||||
"bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
|
|
@ -9,10 +9,10 @@ import {
|
|||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from 'react-hook-form';
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
|
|
@ -23,7 +23,9 @@ type FormFieldContextValue<
|
|||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
|
|
@ -46,7 +48,7 @@ const useFormField = () => {
|
|||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
|
@ -65,26 +67,35 @@ type FormItemContextValue = {
|
|||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div data-slot="form-item" className={cn('grid gap-2', className)} {...props} />
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn('data-[error=true]:text-destructive', className)}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -92,35 +103,40 @@ function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPri
|
|||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? '') : props.children;
|
||||
const body = error ? String(error?.message ?? "") : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
|
|
@ -130,7 +146,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
|||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn('text-destructive text-sm', className)}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,25 @@
|
|||
import * as React from 'react';
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
|
||||
import * as React from "react";
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />;
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = 'center',
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
|
|
@ -24,8 +30,8 @@ function HoverCardContent({
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground 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 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className
|
||||
"bg-popover text-popover-foreground 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 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
import { MinusIcon } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { MinusIcon } from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
|
|
@ -16,16 +16,23 @@ function InputOTP({
|
|||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn('flex items-center gap-2 has-disabled:opacity-50', containerClassName)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-disabled:opacity-50",
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-group" className={cn('flex items-center', className)} {...props} />
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn("flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -33,7 +40,7 @@ function InputOTPSlot({
|
|||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
|
|
@ -44,8 +51,8 @@ function InputOTPSlot({
|
|||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
|
||||
className
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -59,7 +66,7 @@ function InputOTPSlot({
|
|||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,36 +1,49 @@
|
|||
import * as React from 'react';
|
||||
import * as MenubarPrimitive from '@radix-ui/react-menubar';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Menubar({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||
function Menubar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||
return (
|
||||
<MenubarPrimitive.Root
|
||||
data-slot="menubar"
|
||||
className={cn(
|
||||
'bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs',
|
||||
className
|
||||
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarMenu({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarPortal({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return <MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />;
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarTrigger({
|
||||
|
|
@ -41,8 +54,8 @@ function MenubarTrigger({
|
|||
<MenubarPrimitive.Trigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none',
|
||||
className
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -51,7 +64,7 @@ function MenubarTrigger({
|
|||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = 'start',
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
|
|
@ -64,8 +77,8 @@ function MenubarContent({
|
|||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in 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 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md',
|
||||
className
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in 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 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -76,11 +89,11 @@ function MenubarContent({
|
|||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Item
|
||||
|
|
@ -89,7 +102,7 @@ function MenubarItem({
|
|||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -107,7 +120,7 @@ function MenubarCheckboxItem({
|
|||
data-slot="menubar-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
|
|
@ -132,7 +145,7 @@ function MenubarRadioItem({
|
|||
data-slot="menubar-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -157,7 +170,10 @@ function MenubarLabel({
|
|||
<MenubarPrimitive.Label
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -170,23 +186,31 @@ function MenubarSeparator({
|
|||
return (
|
||||
<MenubarPrimitive.Separator
|
||||
data-slot="menubar-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSub({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
|
||||
}
|
||||
|
||||
|
|
@ -203,8 +227,8 @@ function MenubarSubTrigger({
|
|||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8',
|
||||
className
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -222,8 +246,8 @@ function MenubarSubContent({
|
|||
<MenubarPrimitive.SubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className
|
||||
"bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import * as React from 'react';
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
|
|
@ -18,8 +18,8 @@ function NavigationMenu({
|
|||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
|
||||
className
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -36,7 +36,10 @@ function NavigationMenuList({
|
|||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn('group flex flex-1 list-none items-center justify-center gap-1', className)}
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -49,14 +52,14 @@ function NavigationMenuItem({
|
|||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn('relative', className)}
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1'
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
|
|
@ -67,10 +70,10 @@ function NavigationMenuTrigger({
|
|||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), 'group', className)}
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{' '}
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
|
|
@ -87,9 +90,9 @@ function NavigationMenuContent({
|
|||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
|
||||
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
|
||||
className
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -101,12 +104,16 @@ function NavigationMenuViewport({
|
|||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div className={cn('absolute top-full left-0 isolate z-50 flex justify-center')}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center",
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||
className
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -123,7 +130,7 @@ function NavigationMenuLink({
|
|||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -138,8 +145,8 @@ function NavigationMenuIndicator({
|
|||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
|
||||
className
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,64 +1,79 @@
|
|||
import * as React from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn('flex flex-row items-center gap-1', className)}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />;
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
|
||||
React.ComponentProps<'a'>;
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">;
|
||||
|
||||
function PaginationLink({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) {
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
|
|
@ -67,12 +82,15 @@ function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof
|
|||
);
|
||||
}
|
||||
|
||||
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
|
|
@ -81,12 +99,15 @@ function PaginationNext({ className, ...props }: React.ComponentProps<typeof Pag
|
|||
);
|
||||
}
|
||||
|
||||
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn('flex size-9 items-center justify-center', className)}
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
|
|
|
|||
|
|
@ -1,21 +1,25 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = 'center',
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
|
|
@ -26,8 +30,8 @@ function PopoverContent({
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground 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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className
|
||||
"bg-popover text-popover-foreground 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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -35,7 +39,9 @@ function PopoverContent({
|
|||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
|
|
@ -11,7 +11,10 @@ function Progress({
|
|||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', className)}
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||
import { CircleIcon } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
|
|
@ -13,7 +13,7 @@ function RadioGroup({
|
|||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn('grid gap-3', className)}
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -27,8 +27,8 @@ function RadioGroupItem({
|
|||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import * as React from 'react';
|
||||
import { GripVerticalIcon } from 'lucide-react';
|
||||
import * as ResizablePrimitive from 'react-resizable-panels';
|
||||
import * as React from "react";
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
|
|
@ -11,13 +11,18 @@ function ResizablePanelGroup({
|
|||
return (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
|
|
@ -32,8 +37,8 @@ function ResizableHandle({
|
|||
<ResizablePrimitive.PanelResizeHandle
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
|
||||
className
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
|
|
@ -13,7 +13,7 @@ function ScrollArea({
|
|||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn('relative', className)}
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
|
|
@ -30,7 +30,7 @@ function ScrollArea({
|
|||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
|
|
@ -38,10 +38,12 @@ function ScrollBar({
|
|||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
|
||||
className
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,28 +1,34 @@
|
|||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = 'default',
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: 'sm' | 'default';
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
|
|
@ -30,7 +36,7 @@ function SelectTrigger({
|
|||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -45,7 +51,7 @@ function SelectTrigger({
|
|||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = 'popper',
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
|
|
@ -53,10 +59,10 @@ function SelectContent({
|
|||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
"bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
|
|
@ -64,9 +70,9 @@ function SelectContent({
|
|||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -77,11 +83,14 @@ function SelectContent({
|
|||
);
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -97,7 +106,7 @@ function SelectItem({
|
|||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -118,7 +127,7 @@ function SelectSeparator({
|
|||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -131,7 +140,10 @@ function SelectScrollUpButton({
|
|||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
|
|
@ -146,7 +158,10 @@ function SelectScrollDownButton({
|
|||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
|
|
@ -17,8 +17,8 @@ function Separator({
|
|||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
className
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,28 @@
|
|||
import * as React from 'react';
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
|
|
@ -28,8 +34,8 @@ function SheetOverlay({
|
|||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -39,10 +45,10 @@ function SheetOverlay({
|
|||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
|
|
@ -50,16 +56,16 @@ function SheetContent({
|
|||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom' &&
|
||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
className
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -73,31 +79,34 @@ function SheetContent({
|
|||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -110,7 +119,7 @@ function SheetDescription({
|
|||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,34 +1,39 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { VariantProps, cva } from 'class-variance-authority';
|
||||
import { PanelLeftIcon } from 'lucide-react';
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { VariantProps, cva } from "class-variance-authority";
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = '16rem';
|
||||
const SIDEBAR_WIDTH_MOBILE = '18rem';
|
||||
const SIDEBAR_WIDTH_ICON = '3rem';
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: 'expanded' | 'collapsed';
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
|
|
@ -42,7 +47,7 @@ const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
|||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider.');
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
|
|
@ -56,7 +61,7 @@ function SidebarProvider({
|
|||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
|
|
@ -70,7 +75,7 @@ function SidebarProvider({
|
|||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === 'function' ? value(open) : value;
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
|
|
@ -80,7 +85,7 @@ function SidebarProvider({
|
|||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open]
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
|
|
@ -91,19 +96,22 @@ function SidebarProvider({
|
|||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? 'expanded' : 'collapsed';
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
|
|
@ -115,7 +123,7 @@ function SidebarProvider({
|
|||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -125,14 +133,14 @@ function SidebarProvider({
|
|||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
|
||||
className
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -144,26 +152,26 @@ function SidebarProvider({
|
|||
}
|
||||
|
||||
function Sidebar({
|
||||
side = 'left',
|
||||
variant = 'sidebar',
|
||||
collapsible = 'offcanvas',
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
side?: 'left' | 'right';
|
||||
variant?: 'sidebar' | 'floating' | 'inset';
|
||||
collapsible?: 'offcanvas' | 'icon' | 'none';
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === 'none') {
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
|
||||
className
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -182,7 +190,7 @@ function Sidebar({
|
|||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
|
|
@ -201,7 +209,7 @@ function Sidebar({
|
|||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
|
|
@ -210,26 +218,26 @@ function Sidebar({
|
|||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||
className
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -245,7 +253,11 @@ function Sidebar({
|
|||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
|
|
@ -254,7 +266,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
|
|||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-7', className)}
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
|
|
@ -267,7 +279,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
|
|||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
|
|
@ -279,97 +291,103 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
|||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
|
||||
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
|
||||
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
|
||||
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
||||
className
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
'bg-background relative flex w-full flex-1 flex-col',
|
||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
||||
className
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn('bg-background h-8 w-full shadow-none', className)}
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn('bg-sidebar-border mx-2 w-auto', className)}
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||
className
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -379,17 +397,17 @@ function SidebarGroupLabel({
|
|||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'div';
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||
className
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -400,94 +418,97 @@ function SidebarGroupAction({
|
|||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 md:after:hidden',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn('w-full text-sm', className)}
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn('group/menu-item relative', className)}
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: 'h-8 text-sm',
|
||||
sm: 'h-7 text-xs',
|
||||
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & {
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
|
|
@ -505,7 +526,7 @@ function SidebarMenuButton({
|
|||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === 'string') {
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
|
|
@ -517,7 +538,7 @@ function SidebarMenuButton({
|
|||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== 'collapsed' || isMobile}
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
|
@ -529,46 +550,49 @@ function SidebarMenuAction({
|
|||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & {
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 md:after:hidden',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
|
||||
className
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
|
||||
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
@ -579,7 +603,7 @@ function SidebarMenuSkeleton({
|
|||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
|
|
@ -591,16 +615,21 @@ function SidebarMenuSkeleton({
|
|||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
'--skeleton-width': width,
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
|
|
@ -608,27 +637,30 @@ function SidebarMenuSkeleton({
|
|||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn('group/menu-sub-item relative', className)}
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -636,16 +668,16 @@ function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>)
|
|||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = 'md',
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & {
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
|
@ -654,12 +686,12 @@ function SidebarMenuSubButton({
|
|||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
||||
size === 'sm' && 'text-xs',
|
||||
size === 'md' && 'text-sm',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn('bg-accent animate-pulse rounded-md', className)}
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
|
|
@ -14,8 +14,13 @@ function Slider({
|
|||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
|
||||
[value, defaultValue, min, max]
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -26,21 +31,21 @@ function Slider({
|
|||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
|
||||
className
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5'
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { useTheme } from 'next-themes';
|
||||
import { Toaster as Sonner, ToasterProps } from 'sonner';
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme();
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,27 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
|
|
|
|||
|
|
@ -1,90 +1,114 @@
|
|||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||
return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />;
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
|
||||
className
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn('text-muted-foreground mt-4 text-sm', className)}
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,51 +1,63 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn('flex flex-col gap-2', className)}
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
|
||||
className
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn('flex-1 outline-none', className)}
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
import * as React from "react";
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toggleVariants } from '@/components/ui/toggle';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toggleVariants } from "@/components/ui/toggle";
|
||||
|
||||
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
|
|
@ -18,15 +20,16 @@ function ToggleGroup({
|
|||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
|
||||
className
|
||||
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -43,7 +46,8 @@ function ToggleGroupItem({
|
|||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
|
|
@ -56,8 +60,8 @@ function ToggleGroupItem({
|
|||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
|
||||
className
|
||||
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
import * as React from 'react';
|
||||
import * as TogglePrimitive from '@radix-ui/react-toggle';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from "react";
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-2 min-w-9',
|
||||
sm: 'h-8 px-1.5 min-w-8',
|
||||
lg: 'h-10 px-2.5 min-w-10',
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function Toggle({
|
||||
|
|
@ -31,7 +31,8 @@ function Toggle({
|
|||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
|
|
@ -16,7 +16,9 @@ function TooltipProvider({
|
|||
);
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
|
|
@ -24,7 +26,9 @@ function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root
|
|||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
|
|
@ -40,8 +44,8 @@ function TooltipContent({
|
|||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
className
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -8,12 +8,12 @@ import {
|
|||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from './ui/alert-dialog';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Button } from './ui/button';
|
||||
import { Download, RefreshCw, CheckCircle } from 'lucide-react';
|
||||
import { api } from '@/trpc/react';
|
||||
import { toast } from 'sonner';
|
||||
} from "./ui/alert-dialog";
|
||||
import { Progress } from "./ui/progress";
|
||||
import { Button } from "./ui/button";
|
||||
import { Download, RefreshCw, CheckCircle } from "lucide-react";
|
||||
import { api } from "@/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UpdateDialogProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -24,7 +24,11 @@ interface UpdateDialogProps {
|
|||
};
|
||||
}
|
||||
|
||||
export function UpdateDialog({ isOpen, onClose, updateInfo }: UpdateDialogProps) {
|
||||
export function UpdateDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
updateInfo,
|
||||
}: UpdateDialogProps) {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
|
||||
|
|
@ -33,42 +37,45 @@ export function UpdateDialog({ isOpen, onClose, updateInfo }: UpdateDialogProps)
|
|||
enabled: isOpen,
|
||||
refetchInterval: isOpen ? 1000 : false, // Poll every second when dialog is open
|
||||
});
|
||||
const isUpdateAvailableQuery = api.updater.isUpdateAvailable.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
refetchInterval: isOpen ? 1000 : false,
|
||||
});
|
||||
const isUpdateAvailableQuery = api.updater.isUpdateAvailable.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: isOpen,
|
||||
refetchInterval: isOpen ? 1000 : false,
|
||||
},
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
// tRPC mutations
|
||||
const checkForUpdatesMutation = api.updater.checkForUpdates.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Update check completed');
|
||||
toast.success("Update check completed");
|
||||
utils.updater.isUpdateAvailable.invalidate();
|
||||
utils.updater.isCheckingForUpdate.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error checking for updates:', error);
|
||||
toast.error('Failed to check for updates');
|
||||
}
|
||||
console.error("Error checking for updates:", error);
|
||||
toast.error("Failed to check for updates");
|
||||
},
|
||||
});
|
||||
|
||||
const downloadUpdateMutation = api.updater.downloadUpdate.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Update download started');
|
||||
toast.success("Update download started");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error downloading update:', error);
|
||||
toast.error('Failed to download update');
|
||||
console.error("Error downloading update:", error);
|
||||
toast.error("Failed to download update");
|
||||
setIsDownloading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const quitAndInstallMutation = api.updater.quitAndInstall.useMutation({
|
||||
onError: (error) => {
|
||||
console.error('Error installing update:', error);
|
||||
toast.error('Failed to install update');
|
||||
}
|
||||
console.error("Error installing update:", error);
|
||||
toast.error("Failed to install update");
|
||||
},
|
||||
});
|
||||
|
||||
// Get status from queries
|
||||
|
|
@ -82,8 +89,8 @@ export function UpdateDialog({ isOpen, onClose, updateInfo }: UpdateDialogProps)
|
|||
setDownloadProgress(Math.round(progress.percent || 0));
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Download progress subscription error:', error);
|
||||
}
|
||||
console.error("Download progress subscription error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
|
|
@ -188,14 +195,16 @@ export function UpdateDialog({ isOpen, onClose, updateInfo }: UpdateDialogProps)
|
|||
<AlertDialogDescription>
|
||||
{updateInfo?.version && (
|
||||
<>
|
||||
Version {updateInfo.version} has been downloaded and is ready to install.
|
||||
The app will restart to complete the installation.
|
||||
Version {updateInfo.version} has been downloaded and is ready
|
||||
to install. The app will restart to complete the installation.
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onClose}>Install Later</AlertDialogCancel>
|
||||
<AlertDialogCancel onClick={onClose}>
|
||||
Install Later
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleInstallUpdate}>
|
||||
Restart & Install
|
||||
</AlertDialogAction>
|
||||
|
|
@ -235,4 +244,4 @@ export function UpdateDialog({ isOpen, onClose, updateInfo }: UpdateDialogProps)
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import * as React from "react"
|
||||
import type { Vocabulary } from "@/db/schema"
|
||||
import { format } from "date-fns"
|
||||
import { Plus, Trash2, Edit, Book } from "lucide-react"
|
||||
import { api } from "@/trpc/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import * as React from "react";
|
||||
import type { Vocabulary } from "@/db/schema";
|
||||
import { format } from "date-fns";
|
||||
import { Plus, Trash2, Edit, Book } from "lucide-react";
|
||||
import { api } from "@/trpc/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -11,74 +11,75 @@ import {
|
|||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function VocabularyManager() {
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false)
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false);
|
||||
const [newWord, setNewWord] = React.useState({
|
||||
word: "",
|
||||
})
|
||||
});
|
||||
|
||||
// tRPC React Query hooks
|
||||
const vocabularyQuery = api.vocabulary.getVocabulary.useQuery({
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
sortBy: 'dateAdded',
|
||||
sortOrder: 'desc',
|
||||
})
|
||||
sortBy: "dateAdded",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
|
||||
const vocabularyCountQuery = api.vocabulary.getVocabularyCount.useQuery({})
|
||||
const vocabularyCountQuery = api.vocabulary.getVocabularyCount.useQuery({});
|
||||
|
||||
const utils = api.useUtils()
|
||||
const utils = api.useUtils();
|
||||
|
||||
const createVocabularyMutation = api.vocabulary.createVocabularyWord.useMutation({
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch vocabulary data
|
||||
utils.vocabulary.getVocabulary.invalidate()
|
||||
utils.vocabulary.getVocabularyCount.invalidate()
|
||||
setNewWord({ word: "" })
|
||||
setIsAddDialogOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error adding word:', error)
|
||||
}
|
||||
})
|
||||
const createVocabularyMutation =
|
||||
api.vocabulary.createVocabularyWord.useMutation({
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch vocabulary data
|
||||
utils.vocabulary.getVocabulary.invalidate();
|
||||
utils.vocabulary.getVocabularyCount.invalidate();
|
||||
setNewWord({ word: "" });
|
||||
setIsAddDialogOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error adding word:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteVocabularyMutation = api.vocabulary.deleteVocabulary.useMutation({
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch vocabulary data
|
||||
utils.vocabulary.getVocabulary.invalidate()
|
||||
utils.vocabulary.getVocabularyCount.invalidate()
|
||||
utils.vocabulary.getVocabulary.invalidate();
|
||||
utils.vocabulary.getVocabularyCount.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error deleting word:', error)
|
||||
}
|
||||
})
|
||||
console.error("Error deleting word:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddWord = async () => {
|
||||
if (newWord.word.trim()) {
|
||||
createVocabularyMutation.mutate({
|
||||
word: newWord.word.trim().toLowerCase(),
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWord = async (id: number) => {
|
||||
deleteVocabularyMutation.mutate({ id })
|
||||
}
|
||||
deleteVocabularyMutation.mutate({ id });
|
||||
};
|
||||
|
||||
const vocabulary = vocabularyQuery.data || []
|
||||
const totalCount = vocabularyCountQuery.data || 0
|
||||
const loading = vocabularyQuery.isLoading || vocabularyCountQuery.isLoading
|
||||
const vocabulary = vocabularyQuery.data || [];
|
||||
const totalCount = vocabularyCountQuery.data || 0;
|
||||
const loading = vocabularyQuery.isLoading || vocabularyCountQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -102,18 +103,27 @@ export function VocabularyManager() {
|
|||
id="word"
|
||||
placeholder="Enter the word"
|
||||
value={newWord.word}
|
||||
onChange={(e) => setNewWord({ ...newWord, word: e.target.value })}
|
||||
onChange={(e) =>
|
||||
setNewWord({ ...newWord, word: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsAddDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
onClick={handleAddWord}
|
||||
disabled={createVocabularyMutation.isPending || !newWord.word.trim()}
|
||||
disabled={
|
||||
createVocabularyMutation.isPending || !newWord.word.trim()
|
||||
}
|
||||
>
|
||||
{createVocabularyMutation.isPending ? 'Adding...' : 'Add Word'}
|
||||
{createVocabularyMutation.isPending
|
||||
? "Adding..."
|
||||
: "Add Word"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -126,8 +136,12 @@ export function VocabularyManager() {
|
|||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[300px] font-semibold">Word</TableHead>
|
||||
<TableHead className="w-[200px] font-semibold">Date Added</TableHead>
|
||||
<TableHead className="w-[100px] text-right font-semibold">Actions</TableHead>
|
||||
<TableHead className="w-[200px] font-semibold">
|
||||
Date Added
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px] text-right font-semibold">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -136,26 +150,35 @@ export function VocabularyManager() {
|
|||
<TableCell colSpan={3} className="text-center py-12">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading vocabulary...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading vocabulary...
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : vocabulary.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-12 text-muted-foreground">
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-12 text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<Book className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm">No custom vocabulary words yet.</p>
|
||||
<p className="text-xs">Add your first word to get started.</p>
|
||||
<p className="text-xs">
|
||||
Add your first word to get started.
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
vocabulary.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="font-medium py-4">{item.word}</TableCell>
|
||||
<TableCell className="font-medium py-4">
|
||||
{item.word}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground py-4 text-sm">
|
||||
{format(new Date(item.dateAdded), 'MMM d, yyyy')}
|
||||
{format(new Date(item.dateAdded), "MMM d, yyyy")}
|
||||
</TableCell>
|
||||
<TableCell className="py-4">
|
||||
<div className="flex justify-end space-x-1">
|
||||
|
|
@ -163,9 +186,9 @@ export function VocabularyManager() {
|
|||
<Edit className="h-4 w-4" />
|
||||
<span className="sr-only">Edit word</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDeleteWord(item.id)}
|
||||
disabled={deleteVocabularyMutation.isPending}
|
||||
|
|
@ -185,13 +208,14 @@ export function VocabularyManager() {
|
|||
{!loading && vocabulary.length > 0 && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
Showing {vocabulary.length} of {totalCount} word{totalCount !== 1 ? 's' : ''}
|
||||
Showing {vocabulary.length} of {totalCount} word
|
||||
{totalCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span>
|
||||
Total: {totalCount} custom word{totalCount !== 1 ? 's' : ''}
|
||||
Total: {totalCount} custom word{totalCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { VocabularyManager } from './vocabulary-manager';
|
||||
import React from "react";
|
||||
import { VocabularyManager } from "./vocabulary-manager";
|
||||
|
||||
export function VocabularyView() {
|
||||
return <VocabularyManager />;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export interface Model {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'whisper' | 'tts' | 'other';
|
||||
type: "whisper" | "tts" | "other";
|
||||
size: number; // Approximate size in bytes (for UI display only)
|
||||
sizeFormatted: string; // Human readable size (e.g., "~39 MB")
|
||||
description: string;
|
||||
|
|
@ -23,7 +23,7 @@ export interface DownloadedModel {
|
|||
export interface DownloadProgress {
|
||||
modelId: string;
|
||||
progress: number; // 0-100
|
||||
status: 'downloading' | 'paused' | 'cancelling' | 'error';
|
||||
status: "downloading" | "paused" | "cancelling" | "error";
|
||||
bytesDownloaded: number;
|
||||
totalBytes: number;
|
||||
error?: string;
|
||||
|
|
@ -37,58 +37,66 @@ export interface ModelManagerState {
|
|||
// Available Whisper models manifest
|
||||
export const AVAILABLE_MODELS: Model[] = [
|
||||
{
|
||||
id: 'whisper-tiny',
|
||||
name: 'Whisper Tiny',
|
||||
type: 'whisper',
|
||||
id: "whisper-tiny",
|
||||
name: "Whisper Tiny",
|
||||
type: "whisper",
|
||||
size: 77.7 * 1024 * 1024, // ~77.7 MB
|
||||
sizeFormatted: '~78 MB',
|
||||
description: 'Fastest model with basic accuracy. Good for real-time transcription.',
|
||||
downloadUrl: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin',
|
||||
filename: 'ggml-tiny.bin',
|
||||
checksum: 'bd577a113a864445d4c299885e0cb97d4ba92b5f',
|
||||
sizeFormatted: "~78 MB",
|
||||
description:
|
||||
"Fastest model with basic accuracy. Good for real-time transcription.",
|
||||
downloadUrl:
|
||||
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin",
|
||||
filename: "ggml-tiny.bin",
|
||||
checksum: "bd577a113a864445d4c299885e0cb97d4ba92b5f",
|
||||
},
|
||||
{
|
||||
id: 'whisper-base',
|
||||
name: 'Whisper Base',
|
||||
type: 'whisper',
|
||||
id: "whisper-base",
|
||||
name: "Whisper Base",
|
||||
type: "whisper",
|
||||
size: 148 * 1024 * 1024, // ~148 MB
|
||||
sizeFormatted: '~148 MB',
|
||||
description: 'Balanced speed and accuracy. Recommended for most use cases.',
|
||||
downloadUrl: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin',
|
||||
filename: 'ggml-base.bin',
|
||||
checksum: '465707469ff3a37a2b9b8d8f89f2f99de7299dac',
|
||||
sizeFormatted: "~148 MB",
|
||||
description: "Balanced speed and accuracy. Recommended for most use cases.",
|
||||
downloadUrl:
|
||||
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin",
|
||||
filename: "ggml-base.bin",
|
||||
checksum: "465707469ff3a37a2b9b8d8f89f2f99de7299dac",
|
||||
},
|
||||
{
|
||||
id: 'whisper-small',
|
||||
name: 'Whisper Small',
|
||||
type: 'whisper',
|
||||
id: "whisper-small",
|
||||
name: "Whisper Small",
|
||||
type: "whisper",
|
||||
size: 488 * 1024 * 1024, // ~488 MB
|
||||
sizeFormatted: '~488 MB',
|
||||
description: 'Higher accuracy with moderate speed. Good for quality transcription.',
|
||||
downloadUrl: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin',
|
||||
filename: 'ggml-small.bin',
|
||||
checksum: '55356645c2b361a969dfd0ef2c5a50d530afd8d5',
|
||||
sizeFormatted: "~488 MB",
|
||||
description:
|
||||
"Higher accuracy with moderate speed. Good for quality transcription.",
|
||||
downloadUrl:
|
||||
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin",
|
||||
filename: "ggml-small.bin",
|
||||
checksum: "55356645c2b361a969dfd0ef2c5a50d530afd8d5",
|
||||
},
|
||||
{
|
||||
id: 'whisper-medium',
|
||||
name: 'Whisper Medium',
|
||||
type: 'whisper',
|
||||
id: "whisper-medium",
|
||||
name: "Whisper Medium",
|
||||
type: "whisper",
|
||||
size: 1.53 * 1024 * 1024 * 1024, // ~1.53 GB
|
||||
sizeFormatted: '~1.5 GB',
|
||||
description: 'High accuracy model. Slower but more precise transcription.',
|
||||
downloadUrl: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin',
|
||||
filename: 'ggml-medium.bin',
|
||||
checksum: 'fd9727b6e1217c2f614f9b698455c4ffd82463b4',
|
||||
sizeFormatted: "~1.5 GB",
|
||||
description: "High accuracy model. Slower but more precise transcription.",
|
||||
downloadUrl:
|
||||
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin",
|
||||
filename: "ggml-medium.bin",
|
||||
checksum: "fd9727b6e1217c2f614f9b698455c4ffd82463b4",
|
||||
},
|
||||
{
|
||||
id: 'whisper-large-v3',
|
||||
name: 'Whisper Large v3',
|
||||
type: 'whisper',
|
||||
id: "whisper-large-v3",
|
||||
name: "Whisper Large v3",
|
||||
type: "whisper",
|
||||
size: 3.1 * 1024 * 1024 * 1024, // ~3.1 GB
|
||||
sizeFormatted: '~3.1 GB',
|
||||
description: 'Highest accuracy model. Best quality but slowest transcription.',
|
||||
downloadUrl: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin',
|
||||
filename: 'ggml-large-v3.bin',
|
||||
checksum: 'ad82bf6a9043ceed055076d0fd39f5f186ff8062',
|
||||
sizeFormatted: "~3.1 GB",
|
||||
description:
|
||||
"Highest accuracy model. Best quality but slowest transcription.",
|
||||
downloadUrl:
|
||||
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin",
|
||||
filename: "ggml-large-v3.bin",
|
||||
checksum: "ad82bf6a9043ceed055076d0fd39f5f186ff8062",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { eq } from 'drizzle-orm';
|
||||
import { db } from './config';
|
||||
import { appSettings, type NewAppSettings, type AppSettingsData } from './schema';
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "./config";
|
||||
import {
|
||||
appSettings,
|
||||
type NewAppSettings,
|
||||
type AppSettingsData,
|
||||
} from "./schema";
|
||||
|
||||
// Singleton ID for app settings (we only have one settings record)
|
||||
const SETTINGS_ID = 1;
|
||||
|
|
@ -8,25 +12,25 @@ const SETTINGS_ID = 1;
|
|||
// Default settings
|
||||
const defaultSettings: AppSettingsData = {
|
||||
formatterConfig: {
|
||||
provider: 'openrouter',
|
||||
model: 'anthropic/claude-3-haiku',
|
||||
apiKey: '',
|
||||
provider: "openrouter",
|
||||
model: "anthropic/claude-3-haiku",
|
||||
apiKey: "",
|
||||
enabled: false,
|
||||
},
|
||||
ui: {
|
||||
theme: 'system',
|
||||
theme: "system",
|
||||
sidebarOpen: false,
|
||||
currentView: 'Voice Recording',
|
||||
currentView: "Voice Recording",
|
||||
},
|
||||
transcription: {
|
||||
language: 'en',
|
||||
language: "en",
|
||||
autoTranscribe: true,
|
||||
confidenceThreshold: 0.8,
|
||||
enablePunctuation: true,
|
||||
enableTimestamps: false,
|
||||
},
|
||||
recording: {
|
||||
defaultFormat: 'wav',
|
||||
defaultFormat: "wav",
|
||||
sampleRate: 16000,
|
||||
autoStopSilence: true,
|
||||
silenceThreshold: 3,
|
||||
|
|
@ -36,7 +40,10 @@ const defaultSettings: AppSettingsData = {
|
|||
|
||||
// Get all app settings
|
||||
export async function getAppSettings(): Promise<AppSettingsData> {
|
||||
const result = await db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID));
|
||||
const result = await db
|
||||
.select()
|
||||
.from(appSettings)
|
||||
.where(eq(appSettings.id, SETTINGS_ID));
|
||||
|
||||
if (result.length === 0) {
|
||||
// Create default settings if none exist
|
||||
|
|
@ -49,7 +56,7 @@ export async function getAppSettings(): Promise<AppSettingsData> {
|
|||
|
||||
// Update app settings (merges with existing settings)
|
||||
export async function updateAppSettings(
|
||||
newSettings: Partial<AppSettingsData>
|
||||
newSettings: Partial<AppSettingsData>,
|
||||
): Promise<AppSettingsData> {
|
||||
const currentSettings = await getAppSettings();
|
||||
const mergedSettings: AppSettingsData = {
|
||||
|
|
@ -100,7 +107,9 @@ export async function updateAppSettings(
|
|||
}
|
||||
|
||||
// Replace all app settings (complete override)
|
||||
export async function replaceAppSettings(newSettings: AppSettingsData): Promise<AppSettingsData> {
|
||||
export async function replaceAppSettings(
|
||||
newSettings: AppSettingsData,
|
||||
): Promise<AppSettingsData> {
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
|
|
@ -116,7 +125,7 @@ export async function replaceAppSettings(newSettings: AppSettingsData): Promise<
|
|||
|
||||
// Get a specific setting section
|
||||
export async function getSettingsSection<K extends keyof AppSettingsData>(
|
||||
section: K
|
||||
section: K,
|
||||
): Promise<AppSettingsData[K]> {
|
||||
const settings = await getAppSettings();
|
||||
return settings[section];
|
||||
|
|
@ -125,9 +134,11 @@ export async function getSettingsSection<K extends keyof AppSettingsData>(
|
|||
// Update a specific setting section
|
||||
export async function updateSettingsSection<K extends keyof AppSettingsData>(
|
||||
section: K,
|
||||
newData: AppSettingsData[K]
|
||||
newData: AppSettingsData[K],
|
||||
): Promise<AppSettingsData> {
|
||||
return await updateAppSettings({ [section]: newData } as Partial<AppSettingsData>);
|
||||
return await updateAppSettings({
|
||||
[section]: newData,
|
||||
} as Partial<AppSettingsData>);
|
||||
}
|
||||
|
||||
// Reset settings to defaults
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { drizzle } from 'drizzle-orm/libsql';
|
||||
import { migrate } from 'drizzle-orm/libsql/migrator';
|
||||
import { app } from 'electron';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as schema from './schema';
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { app } from "electron";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import * as schema from "./schema";
|
||||
|
||||
// Get the user data directory for storing the database
|
||||
const dbPath = path.join(app.getPath('userData'), 'amical.db');
|
||||
const dbPath = path.join(app.getPath("userData"), "amical.db");
|
||||
|
||||
export const db = drizzle(`file:${dbPath}`, {
|
||||
schema: {
|
||||
|
|
@ -24,28 +24,28 @@ export async function initializeDatabase() {
|
|||
|
||||
try {
|
||||
// Determine the correct migrations folder path
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
||||
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||
let migrationsPath: string;
|
||||
|
||||
if (isDev) {
|
||||
// Development: use source path relative to the app's working directory
|
||||
migrationsPath = path.join(process.cwd(), 'src', 'db', 'migrations');
|
||||
migrationsPath = path.join(process.cwd(), "src", "db", "migrations");
|
||||
} else {
|
||||
// Production: migrations are copied to resources via extraResource
|
||||
migrationsPath = path.join(process.resourcesPath, 'migrations');
|
||||
migrationsPath = path.join(process.resourcesPath, "migrations");
|
||||
}
|
||||
|
||||
console.log('Attempting to run migrations from:', migrationsPath);
|
||||
console.log('__dirname:', __dirname);
|
||||
console.log('process.cwd():', process.cwd());
|
||||
console.log('isDev:', isDev);
|
||||
console.log("Attempting to run migrations from:", migrationsPath);
|
||||
console.log("__dirname:", __dirname);
|
||||
console.log("process.cwd():", process.cwd());
|
||||
console.log("isDev:", isDev);
|
||||
|
||||
// Check if the migrations path exists
|
||||
if (!fs.existsSync(migrationsPath)) {
|
||||
throw new Error(`Migrations folder not found at: ${migrationsPath}`);
|
||||
}
|
||||
|
||||
const journalPath = path.join(migrationsPath, 'meta', '_journal.json');
|
||||
const journalPath = path.join(migrationsPath, "meta", "_journal.json");
|
||||
if (!fs.existsSync(journalPath)) {
|
||||
throw new Error(`Journal file not found at: ${journalPath}`);
|
||||
}
|
||||
|
|
@ -55,11 +55,13 @@ export async function initializeDatabase() {
|
|||
migrationsFolder: migrationsPath,
|
||||
});
|
||||
|
||||
console.log('Database initialized and migrations completed successfully');
|
||||
console.log("Database initialized and migrations completed successfully");
|
||||
isInitialized = true;
|
||||
} catch (error) {
|
||||
console.error('FATAL: Error initializing database:', error);
|
||||
console.error('Application cannot continue without a working database. Exiting...');
|
||||
console.error("FATAL: Error initializing database:", error);
|
||||
console.error(
|
||||
"Application cannot continue without a working database. Exiting...",
|
||||
);
|
||||
|
||||
// Fatal exit - app cannot function without database
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { eq, desc } from 'drizzle-orm';
|
||||
import * as fs from 'fs';
|
||||
import { db } from './config';
|
||||
import { downloadedModels, type DownloadedModel, type NewDownloadedModel } from './schema';
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import * as fs from "fs";
|
||||
import { db } from "./config";
|
||||
import {
|
||||
downloadedModels,
|
||||
type DownloadedModel,
|
||||
type NewDownloadedModel,
|
||||
} from "./schema";
|
||||
|
||||
// Create a new downloaded model record
|
||||
export async function createDownloadedModel(
|
||||
data: Omit<NewDownloadedModel, 'createdAt' | 'updatedAt'>
|
||||
data: Omit<NewDownloadedModel, "createdAt" | "updatedAt">,
|
||||
) {
|
||||
const now = new Date();
|
||||
|
||||
|
|
@ -21,12 +25,18 @@ export async function createDownloadedModel(
|
|||
|
||||
// Get all downloaded models
|
||||
export async function getDownloadedModels() {
|
||||
return await db.select().from(downloadedModels).orderBy(desc(downloadedModels.downloadedAt));
|
||||
return await db
|
||||
.select()
|
||||
.from(downloadedModels)
|
||||
.orderBy(desc(downloadedModels.downloadedAt));
|
||||
}
|
||||
|
||||
// Get downloaded model by ID
|
||||
export async function getDownloadedModelById(id: string) {
|
||||
const result = await db.select().from(downloadedModels).where(eq(downloadedModels.id, id));
|
||||
const result = await db
|
||||
.select()
|
||||
.from(downloadedModels)
|
||||
.where(eq(downloadedModels.id, id));
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +49,7 @@ export async function isModelDownloaded(modelId: string) {
|
|||
// Update downloaded model
|
||||
export async function updateDownloadedModel(
|
||||
id: string,
|
||||
data: Partial<Omit<DownloadedModel, 'id' | 'createdAt'>>
|
||||
data: Partial<Omit<DownloadedModel, "id" | "createdAt">>,
|
||||
) {
|
||||
const updateData = {
|
||||
...data,
|
||||
|
|
@ -57,13 +67,18 @@ export async function updateDownloadedModel(
|
|||
|
||||
// Delete downloaded model
|
||||
export async function deleteDownloadedModel(id: string) {
|
||||
const result = await db.delete(downloadedModels).where(eq(downloadedModels.id, id)).returning();
|
||||
const result = await db
|
||||
.delete(downloadedModels)
|
||||
.where(eq(downloadedModels.id, id))
|
||||
.returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// Get downloaded models as a record (for backward compatibility)
|
||||
export async function getDownloadedModelsRecord(): Promise<Record<string, DownloadedModel>> {
|
||||
export async function getDownloadedModelsRecord(): Promise<
|
||||
Record<string, DownloadedModel>
|
||||
> {
|
||||
const models = await getDownloadedModels();
|
||||
const record: Record<string, DownloadedModel> = {};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { migrate } from 'drizzle-orm/libsql/migrator';
|
||||
import { db } from './config';
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { db } from "./config";
|
||||
|
||||
export async function runMigrations() {
|
||||
try {
|
||||
// Run migrations
|
||||
await migrate(db, { migrationsFolder: './src/db/migrations' });
|
||||
console.log('Migrations completed successfully');
|
||||
await migrate(db, { migrationsFolder: "./src/db/migrations" });
|
||||
console.log("Migrations completed successfully");
|
||||
} catch (error) {
|
||||
console.error('Error running migrations:', error);
|
||||
console.error("Error running migrations:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,72 @@
|
|||
import { sql } from 'drizzle-orm';
|
||||
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
|
||||
import { sql } from "drizzle-orm";
|
||||
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// Transcriptions table
|
||||
export const transcriptions = sqliteTable('transcriptions', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
text: text('text').notNull(),
|
||||
timestamp: integer('timestamp', { mode: 'timestamp' })
|
||||
export const transcriptions = sqliteTable("transcriptions", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
text: text("text").notNull(),
|
||||
timestamp: integer("timestamp", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
language: text('language').default('en'),
|
||||
audioFile: text('audio_file'), // Path to the audio file
|
||||
confidence: real('confidence'), // AI confidence score (0-1)
|
||||
duration: integer('duration'), // Duration in seconds
|
||||
speechModel: text('speech_model'), // Model used for speech recognition
|
||||
formattingModel: text('formatting_model'), // Model used for formatting
|
||||
meta: text('meta', { mode: 'json' }), // Additional metadata as JSON
|
||||
createdAt: integer('created_at', { mode: 'timestamp' })
|
||||
language: text("language").default("en"),
|
||||
audioFile: text("audio_file"), // Path to the audio file
|
||||
confidence: real("confidence"), // AI confidence score (0-1)
|
||||
duration: integer("duration"), // Duration in seconds
|
||||
speechModel: text("speech_model"), // Model used for speech recognition
|
||||
formattingModel: text("formatting_model"), // Model used for formatting
|
||||
meta: text("meta", { mode: "json" }), // Additional metadata as JSON
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' })
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Vocabulary table
|
||||
export const vocabulary = sqliteTable('vocabulary', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
word: text('word').notNull().unique(),
|
||||
dateAdded: integer('date_added', { mode: 'timestamp' })
|
||||
export const vocabulary = sqliteTable("vocabulary", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
word: text("word").notNull().unique(),
|
||||
dateAdded: integer("date_added", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
usageCount: integer('usage_count').default(0), // How many times this word appeared in transcriptions
|
||||
createdAt: integer('created_at', { mode: 'timestamp' })
|
||||
usageCount: integer("usage_count").default(0), // How many times this word appeared in transcriptions
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' })
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Downloaded models table
|
||||
export const downloadedModels = sqliteTable('downloaded_models', {
|
||||
id: text('id').primaryKey(), // Model ID (e.g., 'whisper-large-v3')
|
||||
name: text('name').notNull(),
|
||||
type: text('type').notNull(), // 'whisper', 'llama', etc.
|
||||
localPath: text('local_path').notNull(),
|
||||
downloadedAt: integer('downloaded_at', { mode: 'timestamp' })
|
||||
export const downloadedModels = sqliteTable("downloaded_models", {
|
||||
id: text("id").primaryKey(), // Model ID (e.g., 'whisper-large-v3')
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(), // 'whisper', 'llama', etc.
|
||||
localPath: text("local_path").notNull(),
|
||||
downloadedAt: integer("downloaded_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
size: integer('size').notNull(), // File size in bytes
|
||||
checksum: text('checksum'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' })
|
||||
size: integer("size").notNull(), // File size in bytes
|
||||
checksum: text("checksum"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' })
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// App settings table with typed JSON
|
||||
export const appSettings = sqliteTable('app_settings', {
|
||||
id: integer('id').primaryKey(),
|
||||
data: text('data', { mode: 'json' }).$type<AppSettingsData>().notNull(),
|
||||
version: integer('version').notNull().default(1), // For migrations
|
||||
createdAt: integer('created_at', { mode: 'timestamp' })
|
||||
export const appSettings = sqliteTable("app_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
data: text("data", { mode: "json" }).$type<AppSettingsData>().notNull(),
|
||||
version: integer("version").notNull().default(1), // For migrations
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' })
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
|
@ -74,13 +74,13 @@ export const appSettings = sqliteTable('app_settings', {
|
|||
// Define the shape of our settings JSON
|
||||
export interface AppSettingsData {
|
||||
formatterConfig?: {
|
||||
provider: 'openrouter';
|
||||
provider: "openrouter";
|
||||
model: string;
|
||||
apiKey: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
ui?: {
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
theme: "light" | "dark" | "system";
|
||||
sidebarOpen?: boolean;
|
||||
currentView?: string;
|
||||
windowBounds?: {
|
||||
|
|
@ -98,7 +98,7 @@ export interface AppSettingsData {
|
|||
enableTimestamps: boolean;
|
||||
};
|
||||
recording?: {
|
||||
defaultFormat: 'wav' | 'mp3' | 'flac';
|
||||
defaultFormat: "wav" | "mp3" | "flac";
|
||||
sampleRate: 16000 | 22050 | 44100 | 48000;
|
||||
autoStopSilence: boolean;
|
||||
silenceThreshold: number;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { eq, desc, asc, and, like, count, gte, lte } from 'drizzle-orm';
|
||||
import { db } from './config';
|
||||
import { transcriptions, type Transcription, type NewTranscription } from './schema';
|
||||
import { eq, desc, asc, and, like, count, gte, lte } from "drizzle-orm";
|
||||
import { db } from "./config";
|
||||
import {
|
||||
transcriptions,
|
||||
type Transcription,
|
||||
type NewTranscription,
|
||||
} from "./schema";
|
||||
|
||||
// Create a new transcription
|
||||
export async function createTranscription(
|
||||
data: Omit<NewTranscription, 'id' | 'createdAt' | 'updatedAt'>
|
||||
data: Omit<NewTranscription, "id" | "createdAt" | "updatedAt">,
|
||||
) {
|
||||
const now = new Date();
|
||||
|
||||
|
|
@ -15,7 +19,10 @@ export async function createTranscription(
|
|||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = await db.insert(transcriptions).values(newTranscription).returning();
|
||||
const result = await db
|
||||
.insert(transcriptions)
|
||||
.values(newTranscription)
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
|
|
@ -24,16 +31,25 @@ export async function getTranscriptions(
|
|||
options: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'timestamp' | 'createdAt';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
sortBy?: "timestamp" | "createdAt";
|
||||
sortOrder?: "asc" | "desc";
|
||||
search?: string;
|
||||
} = {}
|
||||
} = {},
|
||||
) {
|
||||
const { limit = 50, offset = 0, sortBy = 'timestamp', sortOrder = 'desc', search } = options;
|
||||
const {
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
sortBy = "timestamp",
|
||||
sortOrder = "desc",
|
||||
search,
|
||||
} = options;
|
||||
|
||||
// Build query with conditional where clause
|
||||
const sortColumn = sortBy === 'timestamp' ? transcriptions.timestamp : transcriptions.createdAt;
|
||||
const orderFn = sortOrder === 'asc' ? asc : desc;
|
||||
const sortColumn =
|
||||
sortBy === "timestamp"
|
||||
? transcriptions.timestamp
|
||||
: transcriptions.createdAt;
|
||||
const orderFn = sortOrder === "asc" ? asc : desc;
|
||||
|
||||
if (search) {
|
||||
return await db
|
||||
|
|
@ -55,14 +71,17 @@ export async function getTranscriptions(
|
|||
|
||||
// Get transcription by ID
|
||||
export async function getTranscriptionById(id: number) {
|
||||
const result = await db.select().from(transcriptions).where(eq(transcriptions.id, id));
|
||||
const result = await db
|
||||
.select()
|
||||
.from(transcriptions)
|
||||
.where(eq(transcriptions.id, id));
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// Update transcription
|
||||
export async function updateTranscription(
|
||||
id: number,
|
||||
data: Partial<Omit<Transcription, 'id' | 'createdAt'>>
|
||||
data: Partial<Omit<Transcription, "id" | "createdAt">>,
|
||||
) {
|
||||
const updateData = {
|
||||
...data,
|
||||
|
|
@ -80,7 +99,10 @@ export async function updateTranscription(
|
|||
|
||||
// Delete transcription
|
||||
export async function deleteTranscription(id: number) {
|
||||
const result = await db.delete(transcriptions).where(eq(transcriptions.id, id)).returning();
|
||||
const result = await db
|
||||
.delete(transcriptions)
|
||||
.where(eq(transcriptions.id, id))
|
||||
.returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
|
@ -100,11 +122,19 @@ export async function getTranscriptionsCount(search?: string) {
|
|||
}
|
||||
|
||||
// Get transcriptions by date range
|
||||
export async function getTranscriptionsByDateRange(startDate: Date, endDate: Date) {
|
||||
export async function getTranscriptionsByDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
) {
|
||||
return await db
|
||||
.select()
|
||||
.from(transcriptions)
|
||||
.where(and(gte(transcriptions.timestamp, startDate), lte(transcriptions.timestamp, endDate)))
|
||||
.where(
|
||||
and(
|
||||
gte(transcriptions.timestamp, startDate),
|
||||
lte(transcriptions.timestamp, endDate),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(transcriptions.timestamp));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { eq, desc, asc, like, count, gt, sql } from 'drizzle-orm';
|
||||
import { db } from './config';
|
||||
import { vocabulary, type Vocabulary, type NewVocabulary } from './schema';
|
||||
import { eq, desc, asc, like, count, gt, sql } from "drizzle-orm";
|
||||
import { db } from "./config";
|
||||
import { vocabulary, type Vocabulary, type NewVocabulary } from "./schema";
|
||||
|
||||
// Create a new vocabulary word
|
||||
export async function createVocabularyWord(
|
||||
data: Omit<NewVocabulary, 'id' | 'createdAt' | 'updatedAt'>
|
||||
data: Omit<NewVocabulary, "id" | "createdAt" | "updatedAt">,
|
||||
) {
|
||||
const now = new Date();
|
||||
|
||||
|
|
@ -24,27 +24,33 @@ export async function getVocabulary(
|
|||
options: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'word' | 'dateAdded' | 'usageCount';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
sortBy?: "word" | "dateAdded" | "usageCount";
|
||||
sortOrder?: "asc" | "desc";
|
||||
search?: string;
|
||||
} = {}
|
||||
} = {},
|
||||
) {
|
||||
const { limit = 50, offset = 0, sortBy = 'dateAdded', sortOrder = 'desc', search } = options;
|
||||
const {
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
sortBy = "dateAdded",
|
||||
sortOrder = "desc",
|
||||
search,
|
||||
} = options;
|
||||
|
||||
// Determine sort column
|
||||
let sortColumn;
|
||||
switch (sortBy) {
|
||||
case 'word':
|
||||
case "word":
|
||||
sortColumn = vocabulary.word;
|
||||
break;
|
||||
case 'usageCount':
|
||||
case "usageCount":
|
||||
sortColumn = vocabulary.usageCount;
|
||||
break;
|
||||
default:
|
||||
sortColumn = vocabulary.dateAdded;
|
||||
}
|
||||
|
||||
const orderFn = sortOrder === 'asc' ? asc : desc;
|
||||
const orderFn = sortOrder === "asc" ? asc : desc;
|
||||
|
||||
// Build query with conditional where clause
|
||||
if (search) {
|
||||
|
|
@ -67,20 +73,26 @@ export async function getVocabulary(
|
|||
|
||||
// Get vocabulary word by ID
|
||||
export async function getVocabularyById(id: number) {
|
||||
const result = await db.select().from(vocabulary).where(eq(vocabulary.id, id));
|
||||
const result = await db
|
||||
.select()
|
||||
.from(vocabulary)
|
||||
.where(eq(vocabulary.id, id));
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// Get vocabulary word by word text
|
||||
export async function getVocabularyByWord(word: string) {
|
||||
const result = await db.select().from(vocabulary).where(eq(vocabulary.word, word.toLowerCase()));
|
||||
const result = await db
|
||||
.select()
|
||||
.from(vocabulary)
|
||||
.where(eq(vocabulary.word, word.toLowerCase()));
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// Update vocabulary word
|
||||
export async function updateVocabulary(
|
||||
id: number,
|
||||
data: Partial<Omit<Vocabulary, 'id' | 'createdAt'>>
|
||||
data: Partial<Omit<Vocabulary, "id" | "createdAt">>,
|
||||
) {
|
||||
const updateData = {
|
||||
...data,
|
||||
|
|
@ -98,7 +110,10 @@ export async function updateVocabulary(
|
|||
|
||||
// Delete vocabulary word
|
||||
export async function deleteVocabulary(id: number) {
|
||||
const result = await db.delete(vocabulary).where(eq(vocabulary.id, id)).returning();
|
||||
const result = await db
|
||||
.delete(vocabulary)
|
||||
.where(eq(vocabulary.id, id))
|
||||
.returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
|
@ -154,7 +169,7 @@ export async function searchVocabulary(searchTerm: string, limit = 20) {
|
|||
|
||||
// Bulk import vocabulary words
|
||||
export async function bulkImportVocabulary(
|
||||
words: Omit<NewVocabulary, 'id' | 'createdAt' | 'updatedAt'>[]
|
||||
words: Omit<NewVocabulary, "id" | "createdAt" | "updatedAt">[],
|
||||
) {
|
||||
const now = new Date();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener('change', onChange);
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { MicVAD } from '@ricky0123/vad-web';
|
||||
import { Mutex } from 'async-mutex';
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { MicVAD } from "@ricky0123/vad-web";
|
||||
import { Mutex } from "async-mutex";
|
||||
|
||||
export interface UseRecordingParams {
|
||||
onAudioChunk: (arrayBuffer: ArrayBuffer, isFinalChunk: boolean) => Promise<void> | void;
|
||||
onAudioChunk: (
|
||||
arrayBuffer: ArrayBuffer,
|
||||
isFinalChunk: boolean,
|
||||
) => Promise<void> | void;
|
||||
chunkDurationMs?: number;
|
||||
onRecordingStartCallback?: () => Promise<void> | void;
|
||||
onRecordingStopCallback?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export type RecordingStatus = 'idle' | 'starting' | 'recording' | 'stopping' | 'error';
|
||||
export type RecordingStatus =
|
||||
| "idle"
|
||||
| "starting"
|
||||
| "recording"
|
||||
| "stopping"
|
||||
| "error";
|
||||
|
||||
export interface UseRecordingOutput {
|
||||
recordingStatus: RecordingStatus; // For detailed state
|
||||
|
|
@ -18,12 +26,15 @@ export interface UseRecordingOutput {
|
|||
stopRecording: () => Promise<void>;
|
||||
}
|
||||
|
||||
const cleanupMediaResources = (vadInstance: MicVAD | null, streamInstance: MediaStream | null) => {
|
||||
const cleanupMediaResources = (
|
||||
vadInstance: MicVAD | null,
|
||||
streamInstance: MediaStream | null,
|
||||
) => {
|
||||
if (vadInstance) {
|
||||
try {
|
||||
vadInstance.destroy();
|
||||
} catch (e) {
|
||||
console.error('Error destroying VAD:', e);
|
||||
console.error("Error destroying VAD:", e);
|
||||
}
|
||||
}
|
||||
if (streamInstance) {
|
||||
|
|
@ -31,11 +42,11 @@ const cleanupMediaResources = (vadInstance: MicVAD | null, streamInstance: Media
|
|||
try {
|
||||
track.stop();
|
||||
} catch (e) {
|
||||
console.error('Error stopping stream track:', e);
|
||||
console.error("Error stopping stream track:", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
console.log('Helper: Media resources cleaned up.');
|
||||
console.log("Helper: Media resources cleaned up.");
|
||||
};
|
||||
|
||||
export const useRecording = ({
|
||||
|
|
@ -44,7 +55,8 @@ export const useRecording = ({
|
|||
onRecordingStartCallback,
|
||||
onRecordingStopCallback,
|
||||
}: UseRecordingParams): UseRecordingOutput => {
|
||||
const [recordingStatus, setRecordingStatus] = useState<RecordingStatus>('idle');
|
||||
const [recordingStatus, setRecordingStatus] =
|
||||
useState<RecordingStatus>("idle");
|
||||
const [voiceDetected, setVoiceDetected] = useState(false);
|
||||
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
|
|
@ -56,7 +68,9 @@ export const useRecording = ({
|
|||
const internalStopRecording = useCallback(
|
||||
async (callStopCallback: boolean) => {
|
||||
// This function assumes mutex is already acquired or not needed (e.g. unmount)
|
||||
console.log('Hook: Internal: Stopping recording and sending final chunk...');
|
||||
console.log(
|
||||
"Hook: Internal: Stopping recording and sending final chunk...",
|
||||
);
|
||||
|
||||
// Send final audio chunk before cleanup
|
||||
try {
|
||||
|
|
@ -65,10 +79,10 @@ export const useRecording = ({
|
|||
const sendFinalChunk = (window as any).currentSendAudioChunk;
|
||||
if (sendFinalChunk) {
|
||||
await sendFinalChunk(true); // Send final chunk
|
||||
console.log('Hook: Final audio chunk sent.');
|
||||
console.log("Hook: Final audio chunk sent.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Hook: Error sending final audio chunk:', error);
|
||||
console.error("Hook: Error sending final audio chunk:", error);
|
||||
}
|
||||
|
||||
// Cleanup all resources
|
||||
|
|
@ -85,41 +99,43 @@ export const useRecording = ({
|
|||
vadRef.current = null;
|
||||
streamRef.current = null;
|
||||
|
||||
setRecordingStatus('idle');
|
||||
setRecordingStatus("idle");
|
||||
setVoiceDetected(false);
|
||||
|
||||
if (callStopCallback && onRecordingStopCallback) {
|
||||
try {
|
||||
await onRecordingStopCallback();
|
||||
console.log('Hook: onRecordingStopCallback executed.');
|
||||
console.log("Hook: onRecordingStopCallback executed.");
|
||||
} catch (e) {
|
||||
console.error('Hook: Error in onRecordingStopCallback:', e);
|
||||
console.error("Hook: Error in onRecordingStopCallback:", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onRecordingStopCallback]
|
||||
[onRecordingStopCallback],
|
||||
);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
await operationMutexRef.current.runExclusive(async () => {
|
||||
// Check status instead of just isRecording for more accurate state
|
||||
if (recordingStatus !== 'idle' && recordingStatus !== 'error') {
|
||||
if (recordingStatus !== "idle" && recordingStatus !== "error") {
|
||||
console.log(`Hook: Start denied. Current status: ${recordingStatus}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingStatus('starting');
|
||||
console.log('Hook: Attempting to start recording (status: starting)...');
|
||||
setRecordingStatus("starting");
|
||||
console.log("Hook: Attempting to start recording (status: starting)...");
|
||||
|
||||
let localStream: MediaStream | null = null;
|
||||
let localVad: MicVAD | null = null;
|
||||
|
||||
try {
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
localStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
});
|
||||
|
||||
if (onRecordingStartCallback) {
|
||||
await onRecordingStartCallback();
|
||||
console.log('Hook: onRecordingStartCallback executed.');
|
||||
console.log("Hook: onRecordingStartCallback executed.");
|
||||
}
|
||||
|
||||
streamRef.current = localStream; // Assign to ref after callback
|
||||
|
|
@ -133,17 +149,20 @@ export const useRecording = ({
|
|||
let pendingAudioChunks: Float32Array[] = [];
|
||||
|
||||
// Load AudioWorklet module
|
||||
await audioContext.audioWorklet.addModule('/audio-recorder-worklet.js');
|
||||
console.log('Hook: AudioWorklet module loaded successfully');
|
||||
await audioContext.audioWorklet.addModule("/audio-recorder-worklet.js");
|
||||
console.log("Hook: AudioWorklet module loaded successfully");
|
||||
|
||||
source = audioContext.createMediaStreamSource(localStream);
|
||||
|
||||
// Create AudioWorklet node
|
||||
audioWorkletNode = new AudioWorkletNode(audioContext, 'audio-recorder-processor');
|
||||
audioWorkletNode = new AudioWorkletNode(
|
||||
audioContext,
|
||||
"audio-recorder-processor",
|
||||
);
|
||||
|
||||
// Handle messages from AudioWorklet
|
||||
audioWorkletNode.port.onmessage = (event) => {
|
||||
if (event.data.type === 'audioData') {
|
||||
if (event.data.type === "audioData") {
|
||||
const audioData = event.data.audioData as Float32Array;
|
||||
const isFinal = event.data.isFinal as boolean;
|
||||
|
||||
|
|
@ -161,7 +180,10 @@ export const useRecording = ({
|
|||
const sendAudioChunk = async (isFinal = false) => {
|
||||
if (pendingAudioChunks.length > 0) {
|
||||
// Combine all pending chunks into one array
|
||||
const totalLength = pendingAudioChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const totalLength = pendingAudioChunks.reduce(
|
||||
(sum, chunk) => sum + chunk.length,
|
||||
0,
|
||||
);
|
||||
const combinedChunk = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
|
|
@ -173,16 +195,16 @@ export const useRecording = ({
|
|||
// Convert Float32Array to ArrayBuffer for IPC
|
||||
const arrayBuffer = combinedChunk.buffer.slice(
|
||||
combinedChunk.byteOffset,
|
||||
combinedChunk.byteOffset + combinedChunk.byteLength
|
||||
combinedChunk.byteOffset + combinedChunk.byteLength,
|
||||
);
|
||||
|
||||
try {
|
||||
await onAudioChunk(arrayBuffer, isFinal);
|
||||
console.log(
|
||||
`Hook: Sent audio chunk: ${combinedChunk.length} samples, final: ${isFinal}`
|
||||
`Hook: Sent audio chunk: ${combinedChunk.length} samples, final: ${isFinal}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Hook: Error processing audio chunk:', error);
|
||||
console.error("Hook: Error processing audio chunk:", error);
|
||||
}
|
||||
|
||||
pendingAudioChunks = []; // Clear chunks after sending
|
||||
|
|
@ -196,7 +218,7 @@ export const useRecording = ({
|
|||
|
||||
// Connect the audio processing chain
|
||||
source.connect(audioWorkletNode);
|
||||
console.log('Hook: Connected AudioWorklet processing chain');
|
||||
console.log("Hook: Connected AudioWorklet processing chain");
|
||||
|
||||
// Store cleanup functions for Web Audio API
|
||||
const cleanup = () => {
|
||||
|
|
@ -206,7 +228,7 @@ export const useRecording = ({
|
|||
}
|
||||
if (audioWorkletNode) {
|
||||
// Send stop command to worklet
|
||||
audioWorkletNode.port.postMessage({ command: 'stop' });
|
||||
audioWorkletNode.port.postMessage({ command: "stop" });
|
||||
audioWorkletNode.disconnect();
|
||||
audioWorkletNode = null;
|
||||
}
|
||||
|
|
@ -214,50 +236,55 @@ export const useRecording = ({
|
|||
source.disconnect();
|
||||
source = null;
|
||||
}
|
||||
if (audioContext && audioContext.state !== 'closed') {
|
||||
if (audioContext && audioContext.state !== "closed") {
|
||||
audioContext.close();
|
||||
}
|
||||
console.log('Hook: Cleaned up AudioWorklet resources');
|
||||
console.log("Hook: Cleaned up AudioWorklet resources");
|
||||
};
|
||||
|
||||
// Store references for cleanup and final chunk sending
|
||||
(window as any).currentWebAudioCleanup = cleanup;
|
||||
(window as any).currentSendAudioChunk = sendAudioChunk;
|
||||
|
||||
console.log(`Hook: AudioWorklet recording started, chunk duration ${chunkDurationMs}ms.`);
|
||||
console.log(
|
||||
`Hook: AudioWorklet recording started, chunk duration ${chunkDurationMs}ms.`,
|
||||
);
|
||||
|
||||
localVad = await MicVAD.new({
|
||||
stream: localStream,
|
||||
model: 'v5',
|
||||
model: "v5",
|
||||
onSpeechStart: () => {
|
||||
console.log('VAD: Speech started');
|
||||
console.log("VAD: Speech started");
|
||||
setVoiceDetected(true);
|
||||
},
|
||||
onSpeechEnd: () => {
|
||||
console.log('VAD: Speech ended');
|
||||
console.log("VAD: Speech ended");
|
||||
setVoiceDetected(false);
|
||||
},
|
||||
});
|
||||
vadRef.current = localVad;
|
||||
localVad.start();
|
||||
console.log('Hook: VAD started (status: starting).');
|
||||
console.log("Hook: VAD started (status: starting).");
|
||||
|
||||
setRecordingStatus('recording');
|
||||
console.log('Hook: Recording fully started (status: recording).');
|
||||
setRecordingStatus("recording");
|
||||
console.log("Hook: Recording fully started (status: recording).");
|
||||
} catch (err) {
|
||||
console.error('Hook: Error starting recording:', err);
|
||||
console.error("Hook: Error starting recording:", err);
|
||||
cleanupMediaResources(localVad, localStream);
|
||||
streamRef.current = null; // Ensure refs are cleared on error
|
||||
vadRef.current = null;
|
||||
|
||||
setRecordingStatus('error');
|
||||
setRecordingStatus("error");
|
||||
setVoiceDetected(false);
|
||||
if (onRecordingStopCallback) {
|
||||
// If start callback was called, call stop callback
|
||||
try {
|
||||
await onRecordingStopCallback();
|
||||
} catch (e) {
|
||||
console.error('Hook: Error in onRecordingStopCallback during start error:', e);
|
||||
console.error(
|
||||
"Hook: Error in onRecordingStopCallback during start error:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -273,14 +300,14 @@ export const useRecording = ({
|
|||
const stopRecording = useCallback(async () => {
|
||||
await operationMutexRef.current.runExclusive(async () => {
|
||||
// Check status for more accurate state
|
||||
if (recordingStatus !== 'recording' && recordingStatus !== 'starting') {
|
||||
if (recordingStatus !== "recording" && recordingStatus !== "starting") {
|
||||
console.log(`Hook: Stop called but status is ${recordingStatus}.`);
|
||||
// If it's 'stopping', we are already on it. If 'idle' or 'error', nothing to stop.
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Hook: Attempting to stop recording (status: stopping)...');
|
||||
setRecordingStatus('stopping');
|
||||
console.log("Hook: Attempting to stop recording (status: stopping)...");
|
||||
setRecordingStatus("stopping");
|
||||
// internalStopRecording will handle the rest, including setting isAwaitingFinalChunk
|
||||
await internalStopRecording(true); // true to callStopCallback if applicable
|
||||
});
|
||||
|
|
@ -302,7 +329,7 @@ export const useRecording = ({
|
|||
// If the component unmounts abruptly, we prioritize resource release.
|
||||
|
||||
return () => {
|
||||
console.log('Hook: Unmounting...');
|
||||
console.log("Hook: Unmounting...");
|
||||
|
||||
// Directly clean up resources using captured refs.
|
||||
// This avoids issues with stale state in async mutex operations during unmount.
|
||||
|
|
@ -329,11 +356,16 @@ export const useRecording = ({
|
|||
// The expectation is that the user of the hook calls stopRecording and awaits it before unmounting
|
||||
// if graceful shutdown with all callbacks is critical.
|
||||
// This unmount is a "best effort" to release browser resources.
|
||||
console.log('Hook: Unmount cleanup finished.');
|
||||
console.log("Hook: Unmount cleanup finished.");
|
||||
};
|
||||
}, []); // EMPTY DEPENDENCY ARRAY FOR UNMOUNT CLEANUP
|
||||
|
||||
console.log('Hook: Render. status:', recordingStatus, 'voice:', voiceDetected);
|
||||
console.log(
|
||||
"Hook: Render. status:",
|
||||
recordingStatus,
|
||||
"voice:",
|
||||
voiceDetected,
|
||||
);
|
||||
return {
|
||||
recordingStatus,
|
||||
voiceDetected,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
|
|
|
|||
|
|
@ -1,38 +1,46 @@
|
|||
import dotenv from 'dotenv';
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
import log from 'electron-log';
|
||||
import { app } from 'electron';
|
||||
import path from 'node:path';
|
||||
import log from "electron-log";
|
||||
import { app } from "electron";
|
||||
import path from "node:path";
|
||||
|
||||
// Configure electron-log immediately when module is imported
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
||||
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||
|
||||
// Configure main logger - check for LOG_LEVEL override
|
||||
const envLogLevel = process.env.LOG_LEVEL as 'error' | 'warn' | 'info' | 'debug' | undefined;
|
||||
const defaultFileLevel: 'debug' | 'info' = isDev ? 'debug' : 'info';
|
||||
const defaultConsoleLevel: 'debug' | 'warn' = isDev ? 'debug' : 'warn';
|
||||
const envLogLevel = process.env.LOG_LEVEL as
|
||||
| "error"
|
||||
| "warn"
|
||||
| "info"
|
||||
| "debug"
|
||||
| undefined;
|
||||
const defaultFileLevel: "debug" | "info" = isDev ? "debug" : "info";
|
||||
const defaultConsoleLevel: "debug" | "warn" = isDev ? "debug" : "warn";
|
||||
|
||||
log.transports.file.level = envLogLevel || defaultFileLevel;
|
||||
log.transports.console.level = envLogLevel || defaultConsoleLevel;
|
||||
|
||||
// Configure file transport
|
||||
log.transports.file.maxSize = 10 * 1024 * 1024; // 10MB
|
||||
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}';
|
||||
log.transports.file.format =
|
||||
"[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}";
|
||||
|
||||
// Set custom log file path
|
||||
const logPath = isDev
|
||||
? path.join(app.getPath('userData'), 'logs', 'amical-dev.log')
|
||||
: path.join(app.getPath('logs'), 'amical.log');
|
||||
? path.join(app.getPath("userData"), "logs", "amical-dev.log")
|
||||
: path.join(app.getPath("logs"), "amical.log");
|
||||
|
||||
log.transports.file.resolvePathFn = () => logPath;
|
||||
|
||||
// Configure console transport for better development experience
|
||||
if (isDev) {
|
||||
log.transports.console.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}';
|
||||
log.transports.console.format =
|
||||
"[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}";
|
||||
log.transports.console.useStyles = true;
|
||||
} else {
|
||||
log.transports.console.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}';
|
||||
log.transports.console.format =
|
||||
"[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}";
|
||||
log.transports.console.useStyles = false;
|
||||
}
|
||||
|
||||
|
|
@ -48,28 +56,28 @@ if (!isDev) {
|
|||
// -----------------------------------------------
|
||||
// `LOG_DEBUG_SCOPES` can be a comma-separated list of scope names (main,ai,swift)
|
||||
// or regex patterns wrapped in slashes (e.g. /ai.*/, /.*/)
|
||||
const rawDebugScopes = (process.env.LOG_DEBUG_SCOPES ?? '')
|
||||
.split(',')
|
||||
const rawDebugScopes = (process.env.LOG_DEBUG_SCOPES ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Utility: escape regex special chars for exact-match tokens
|
||||
function escapeRegExp(str: string) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
const debugScopePatterns: RegExp[] = rawDebugScopes.map((token) => {
|
||||
if (token.startsWith('/') && token.endsWith('/') && token.length > 1) {
|
||||
if (token.startsWith("/") && token.endsWith("/") && token.length > 1) {
|
||||
// Regex pattern (strip the leading & trailing slashes)
|
||||
const pattern = token.slice(1, -1);
|
||||
try {
|
||||
return new RegExp(pattern, 'i');
|
||||
return new RegExp(pattern, "i");
|
||||
} catch {
|
||||
// Fall through to exact match if regex is invalid
|
||||
}
|
||||
}
|
||||
// Treat as exact scope name
|
||||
return new RegExp(`^${escapeRegExp(token)}$`, 'i');
|
||||
return new RegExp(`^${escapeRegExp(token)}$`, "i");
|
||||
});
|
||||
|
||||
export function isScopeDebug(scope: string): boolean {
|
||||
|
|
@ -80,7 +88,7 @@ export function isScopeDebug(scope: string): boolean {
|
|||
if (debugScopePatterns.length > 0) {
|
||||
log.hooks.push((message) => {
|
||||
// Only filter debug messages
|
||||
if (message.level !== 'debug') return message;
|
||||
if (message.level !== "debug") return message;
|
||||
|
||||
// Check if this scope should have debug enabled
|
||||
const scope = message.scope;
|
||||
|
|
@ -102,24 +110,24 @@ function createLoggerForScope(scope: string) {
|
|||
|
||||
// Create scoped loggers for different modules
|
||||
export const logger = {
|
||||
main: createLoggerForScope('main'),
|
||||
ipc: createLoggerForScope('ipc'),
|
||||
renderer: createLoggerForScope('renderer'),
|
||||
network: createLoggerForScope('network'),
|
||||
audio: createLoggerForScope('audio'),
|
||||
ai: createLoggerForScope('ai'),
|
||||
swift: createLoggerForScope('swift'),
|
||||
ui: createLoggerForScope('ui'),
|
||||
db: createLoggerForScope('db'),
|
||||
updater: createLoggerForScope('updater'),
|
||||
main: createLoggerForScope("main"),
|
||||
ipc: createLoggerForScope("ipc"),
|
||||
renderer: createLoggerForScope("renderer"),
|
||||
network: createLoggerForScope("network"),
|
||||
audio: createLoggerForScope("audio"),
|
||||
ai: createLoggerForScope("ai"),
|
||||
swift: createLoggerForScope("swift"),
|
||||
ui: createLoggerForScope("ui"),
|
||||
db: createLoggerForScope("db"),
|
||||
updater: createLoggerForScope("updater"),
|
||||
};
|
||||
|
||||
// Log startup information
|
||||
logger.main.info('Logger initialized', {
|
||||
logger.main.info("Logger initialized", {
|
||||
isDev,
|
||||
fileLogLevel: log.transports.file.level,
|
||||
consoleLogLevel: log.transports.console.level,
|
||||
envLogLevel: envLogLevel || 'not set',
|
||||
envLogLevel: envLogLevel || "not set",
|
||||
logPath,
|
||||
version: app.getVersion(),
|
||||
platform: process.platform,
|
||||
|
|
@ -135,7 +143,11 @@ export function createScopedLogger(scope: string) {
|
|||
}
|
||||
|
||||
// Error handling utilities
|
||||
export function logError(error: Error, context?: string, metadata?: Record<string, any>) {
|
||||
export function logError(
|
||||
error: Error,
|
||||
context?: string,
|
||||
metadata?: Record<string, any>,
|
||||
) {
|
||||
const errorInfo = {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
|
|
@ -144,13 +156,13 @@ export function logError(error: Error, context?: string, metadata?: Record<strin
|
|||
...metadata,
|
||||
};
|
||||
|
||||
logger?.main.error('Error occurred:', errorInfo);
|
||||
logger?.main.error("Error occurred:", errorInfo);
|
||||
}
|
||||
|
||||
export function logPerformance(
|
||||
operation: string,
|
||||
startTime: number,
|
||||
metadata?: Record<string, any>
|
||||
metadata?: Record<string, any>,
|
||||
) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger?.main.info(`Performance: ${operation}`, {
|
||||
|
|
@ -161,7 +173,7 @@ export function logPerformance(
|
|||
|
||||
// Development helpers
|
||||
export function logDebugInfo(component: string, data: any) {
|
||||
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
|
||||
if (process.env.NODE_ENV === "development" || !app.isPackaged) {
|
||||
logger?.main.debug(`[${component}]`, data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Load .env file FIRST before any other imports
|
||||
import dotenv from 'dotenv';
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
import {
|
||||
|
|
@ -10,26 +10,29 @@ import {
|
|||
ipcMain,
|
||||
screen,
|
||||
clipboard,
|
||||
} from 'electron';
|
||||
import path from 'node:path';
|
||||
import fsPromises from 'node:fs/promises'; // For reading the audio file (async)
|
||||
import started from 'electron-squirrel-startup';
|
||||
import { initializeDatabase } from '../db/config';
|
||||
import { HelperEvent, KeyEventPayload } from '@amical/types';
|
||||
import { logger, logError, logPerformance } from './logger';
|
||||
import { AudioCapture } from '../modules/audio/audio-capture';
|
||||
import { setupApplicationMenu } from './menu';
|
||||
import { AiService } from '../modules/ai/ai-service';
|
||||
import { SwiftIOBridge } from './swift-io-bridge'; // Added import
|
||||
import { DownloadedModel } from '../constants/models';
|
||||
import { ModelManagerService } from '../modules/models/model-manager';
|
||||
import { LocalWhisperClient } from '../modules/ai/local-whisper-client';
|
||||
import { TranscriptionSession, ChunkData } from '../modules/transcription/transcription-session';
|
||||
import { ContextualTranscriptionManager } from '../modules/transcription/contextual-transcription-manager';
|
||||
import { SettingsService } from '../modules/settings';
|
||||
import { createIPCHandler } from 'electron-trpc-experimental/main';
|
||||
import { router } from '../trpc/router';
|
||||
import { AutoUpdaterService } from './services/auto-updater';
|
||||
} from "electron";
|
||||
import path from "node:path";
|
||||
import fsPromises from "node:fs/promises"; // For reading the audio file (async)
|
||||
import started from "electron-squirrel-startup";
|
||||
import { initializeDatabase } from "../db/config";
|
||||
import { HelperEvent, KeyEventPayload } from "@amical/types";
|
||||
import { logger, logError, logPerformance } from "./logger";
|
||||
import { AudioCapture } from "../modules/audio/audio-capture";
|
||||
import { setupApplicationMenu } from "./menu";
|
||||
import { AiService } from "../modules/ai/ai-service";
|
||||
import { SwiftIOBridge } from "./swift-io-bridge"; // Added import
|
||||
import { DownloadedModel } from "../constants/models";
|
||||
import { ModelManagerService } from "../modules/models/model-manager";
|
||||
import { LocalWhisperClient } from "../modules/ai/local-whisper-client";
|
||||
import {
|
||||
TranscriptionSession,
|
||||
ChunkData,
|
||||
} from "../modules/transcription/transcription-session";
|
||||
import { ContextualTranscriptionManager } from "../modules/transcription/contextual-transcription-manager";
|
||||
import { SettingsService } from "../modules/settings";
|
||||
import { createIPCHandler } from "electron-trpc-experimental/main";
|
||||
import { router } from "../trpc/router";
|
||||
import { AutoUpdaterService } from "./services/auto-updater";
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
|
|
@ -51,17 +54,19 @@ let currentWindowDisplayId: number | null = null; // For tracking current displa
|
|||
let activeSpaceChangeSubscriptionId: number | null = null; // For display change notifications
|
||||
|
||||
// New chunk-based transcription variables
|
||||
let contextualTranscriptionManager: ContextualTranscriptionManager | null = null;
|
||||
const activeTranscriptionSessions: Map<string, TranscriptionSession> = new Map();
|
||||
let contextualTranscriptionManager: ContextualTranscriptionManager | null =
|
||||
null;
|
||||
const activeTranscriptionSessions: Map<string, TranscriptionSession> =
|
||||
new Map();
|
||||
let autoUpdaterService: AutoUpdaterService | null = null;
|
||||
|
||||
// Store is imported from '../lib/store' and is database-backed
|
||||
|
||||
// Function to create the local transcription client
|
||||
const createTranscriptionClient = () => {
|
||||
logger.ai.info('Using local Whisper inference');
|
||||
logger.ai.info("Using local Whisper inference");
|
||||
if (!localWhisperClient) {
|
||||
throw new Error('Local Whisper client not initialized');
|
||||
throw new Error("Local Whisper client not initialized");
|
||||
}
|
||||
return localWhisperClient;
|
||||
};
|
||||
|
|
@ -71,25 +76,32 @@ const createTranscriptionClient = () => {
|
|||
const requestPermissions = async () => {
|
||||
try {
|
||||
// Request accessibility permissions
|
||||
if (process.platform === 'darwin') {
|
||||
const accessibilityEnabled = systemPreferences.isTrustedAccessibilityClient(false);
|
||||
if (process.platform === "darwin") {
|
||||
const accessibilityEnabled =
|
||||
systemPreferences.isTrustedAccessibilityClient(false);
|
||||
if (!accessibilityEnabled) {
|
||||
// On macOS, we need to use a different approach for accessibility permissions
|
||||
// The user will need to grant accessibility permissions through System Preferences
|
||||
console.log(
|
||||
'Please enable accessibility permissions in System Preferences > Security & Privacy > Privacy > Accessibility'
|
||||
"Please enable accessibility permissions in System Preferences > Security & Privacy > Privacy > Accessibility",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Request microphone permissions
|
||||
const microphoneEnabled = systemPreferences.getMediaAccessStatus('microphone');
|
||||
logger.main.info('Microphone access status:', { status: microphoneEnabled });
|
||||
if (microphoneEnabled !== 'granted') {
|
||||
await systemPreferences.askForMediaAccess('microphone');
|
||||
const microphoneEnabled =
|
||||
systemPreferences.getMediaAccessStatus("microphone");
|
||||
logger.main.info("Microphone access status:", {
|
||||
status: microphoneEnabled,
|
||||
});
|
||||
if (microphoneEnabled !== "granted") {
|
||||
await systemPreferences.askForMediaAccess("microphone");
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error instanceof Error ? error : new Error(String(error)), 'requesting permissions');
|
||||
logError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
"requesting permissions",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -103,11 +115,11 @@ const createOrShowMainWindow = () => {
|
|||
width: 1200,
|
||||
height: 800,
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarStyle: "hidden",
|
||||
trafficLightPosition: { x: 20, y: 16 },
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
|
|
@ -115,9 +127,11 @@ const createOrShowMainWindow = () => {
|
|||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
|
||||
mainWindow.loadFile(
|
||||
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`),
|
||||
);
|
||||
}
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
if (autoUpdaterService) {
|
||||
autoUpdaterService.setMainWindow(null);
|
||||
|
|
@ -127,7 +141,9 @@ const createOrShowMainWindow = () => {
|
|||
// Update tRPC handler to include the main window
|
||||
createIPCHandler({
|
||||
router,
|
||||
windows: [mainWindow, floatingButtonWindow].filter(Boolean) as BrowserWindow[],
|
||||
windows: [mainWindow, floatingButtonWindow].filter(
|
||||
Boolean,
|
||||
) as BrowserWindow[],
|
||||
});
|
||||
|
||||
// Set main window reference for auto-updater
|
||||
|
|
@ -152,7 +168,7 @@ const createFloatingButtonWindow = () => {
|
|||
focusable: false,
|
||||
hasShadow: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
|
|
@ -163,18 +179,20 @@ const createFloatingButtonWindow = () => {
|
|||
|
||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||
const devUrl = new URL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
|
||||
devUrl.pathname = 'fab.html';
|
||||
devUrl.pathname = "fab.html";
|
||||
floatingButtonWindow.loadURL(devUrl.toString());
|
||||
} else {
|
||||
floatingButtonWindow.loadFile(
|
||||
path.join(__dirname, `../renderer/${WIDGET_WINDOW_VITE_NAME}/fab.html`)
|
||||
path.join(__dirname, `../renderer/${WIDGET_WINDOW_VITE_NAME}/fab.html`),
|
||||
);
|
||||
}
|
||||
|
||||
// Set a higher level for macOS to stay on top of fullscreen apps
|
||||
if (process.platform === 'darwin') {
|
||||
floatingButtonWindow.setAlwaysOnTop(true, 'floating', 1);
|
||||
floatingButtonWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
if (process.platform === "darwin") {
|
||||
floatingButtonWindow.setAlwaysOnTop(true, "floating", 1);
|
||||
floatingButtonWindow.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
});
|
||||
floatingButtonWindow.setHiddenInMissionControl(true);
|
||||
}
|
||||
|
||||
|
|
@ -184,13 +202,18 @@ const createFloatingButtonWindow = () => {
|
|||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.on('ready', async () => {
|
||||
app.on("ready", async () => {
|
||||
// Initialize database and run migrations first
|
||||
try {
|
||||
await initializeDatabase();
|
||||
logger.db.info('Database initialized and migrations completed successfully');
|
||||
logger.db.info(
|
||||
"Database initialized and migrations completed successfully",
|
||||
);
|
||||
} catch (error) {
|
||||
logError(error instanceof Error ? error : new Error(String(error)), 'initializing database');
|
||||
logError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
"initializing database",
|
||||
);
|
||||
// You might want to handle this error differently, perhaps showing a dialog to the user
|
||||
}
|
||||
|
||||
|
|
@ -203,7 +226,7 @@ app.on('ready', async () => {
|
|||
windows: [floatingButtonWindow!],
|
||||
});
|
||||
|
||||
if (process.platform === 'darwin' && app.dock) {
|
||||
if (process.platform === "darwin" && app.dock) {
|
||||
app.dock.show();
|
||||
}
|
||||
|
||||
|
|
@ -223,11 +246,13 @@ app.on('ready', async () => {
|
|||
(globalThis as any).logger = logger;
|
||||
|
||||
// Initialize Contextual Transcription Manager
|
||||
contextualTranscriptionManager = new ContextualTranscriptionManager(modelManagerService);
|
||||
contextualTranscriptionManager = new ContextualTranscriptionManager(
|
||||
modelManagerService,
|
||||
);
|
||||
|
||||
// Initialize Auto-Updater Service
|
||||
autoUpdaterService = new AutoUpdaterService();
|
||||
|
||||
|
||||
// Make auto-updater service available globally for tRPC
|
||||
(globalThis as any).autoUpdaterService = autoUpdaterService;
|
||||
|
||||
|
|
@ -249,25 +274,28 @@ app.on('ready', async () => {
|
|||
const formatterConfig = await settingsService.getFormatterConfig();
|
||||
if (formatterConfig) {
|
||||
aiService.configureFormatter(formatterConfig);
|
||||
logger.ai.info('Formatter configured', {
|
||||
logger.ai.info("Formatter configured", {
|
||||
provider: formatterConfig.provider,
|
||||
enabled: formatterConfig.enabled,
|
||||
});
|
||||
}
|
||||
} catch (formatterError) {
|
||||
logger.ai.warn('Failed to load formatter configuration:', formatterError);
|
||||
logger.ai.warn("Failed to load formatter configuration:", formatterError);
|
||||
}
|
||||
|
||||
logger.ai.info('AI Service initialized', {
|
||||
client: 'Local Whisper',
|
||||
logger.ai.info("AI Service initialized", {
|
||||
client: "Local Whisper",
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error instanceof Error ? error : new Error(String(error)), 'initializing AI Service');
|
||||
logger.ai.warn('Transcription will not work until configuration is fixed');
|
||||
logError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
"initializing AI Service",
|
||||
);
|
||||
logger.ai.warn("Transcription will not work until configuration is fixed");
|
||||
aiService = null;
|
||||
}
|
||||
|
||||
audioCapture.on('recording-finished', async (filePath: string) => {
|
||||
audioCapture.on("recording-finished", async (filePath: string) => {
|
||||
// Ensure AI service is available and up-to-date
|
||||
if (!aiService) {
|
||||
try {
|
||||
|
|
@ -280,53 +308,60 @@ app.on('ready', async () => {
|
|||
const formatterConfig = await settingsService.getFormatterConfig();
|
||||
if (formatterConfig) {
|
||||
aiService.configureFormatter(formatterConfig);
|
||||
logger.ai.info('Formatter reconfigured', {
|
||||
logger.ai.info("Formatter reconfigured", {
|
||||
provider: formatterConfig.provider,
|
||||
enabled: formatterConfig.enabled,
|
||||
});
|
||||
}
|
||||
} catch (formatterError) {
|
||||
logger.ai.warn('Failed to reload formatter configuration:', formatterError);
|
||||
logger.ai.warn(
|
||||
"Failed to reload formatter configuration:",
|
||||
formatterError,
|
||||
);
|
||||
}
|
||||
|
||||
logger.ai.info('AI Service reinitialized', {
|
||||
client: 'Local Whisper',
|
||||
logger.ai.info("AI Service reinitialized", {
|
||||
client: "Local Whisper",
|
||||
});
|
||||
} catch (error) {
|
||||
logError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
'reinitializing AI Service'
|
||||
"reinitializing AI Service",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.audio.info('Recording finished', { filePath });
|
||||
logger.audio.info("Recording finished", { filePath });
|
||||
if (aiService) {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const audioBuffer = await fsPromises.readFile(filePath);
|
||||
logger.audio.info('Audio file read', {
|
||||
logger.audio.info("Audio file read", {
|
||||
size: audioBuffer.length,
|
||||
sizeKB: Math.round(audioBuffer.length / 1024),
|
||||
});
|
||||
|
||||
const transcription = await aiService.transcribeAudio(audioBuffer);
|
||||
logPerformance('audio transcription', startTime, {
|
||||
logPerformance("audio transcription", startTime, {
|
||||
audioSizeKB: Math.round(audioBuffer.length / 1024),
|
||||
transcriptionLength: transcription?.length || 0,
|
||||
});
|
||||
logger.ai.info('Transcription completed', {
|
||||
logger.ai.info("Transcription completed", {
|
||||
resultLength: transcription?.length || 0,
|
||||
hasResult: !!transcription,
|
||||
});
|
||||
|
||||
// Copy transcription to clipboard
|
||||
if (transcription && typeof transcription === 'string') {
|
||||
logger.main.info('Transcription pasted to active application');
|
||||
if (transcription && typeof transcription === "string") {
|
||||
logger.main.info("Transcription pasted to active application");
|
||||
// Attempt to paste into the active application
|
||||
swiftIOBridgeClientInstance!.call('pasteText', { transcript: transcription });
|
||||
swiftIOBridgeClientInstance!.call("pasteText", {
|
||||
transcript: transcription,
|
||||
});
|
||||
} else {
|
||||
logger.main.warn('Transcription result was empty or not a string, not copying');
|
||||
logger.main.warn(
|
||||
"Transcription result was empty or not a string, not copying",
|
||||
);
|
||||
}
|
||||
|
||||
// Optionally, delete the audio file after processing
|
||||
|
|
@ -335,21 +370,21 @@ app.on('ready', async () => {
|
|||
} catch (error) {
|
||||
logError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
'transcription or file handling'
|
||||
"transcription or file handling",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.ai.warn('AI Service not available, cannot transcribe audio');
|
||||
logger.ai.warn("AI Service not available, cannot transcribe audio");
|
||||
}
|
||||
});
|
||||
|
||||
audioCapture.on('recording-error', (error: Error) => {
|
||||
console.error('Main: Received recording error from AudioCapture:', error);
|
||||
audioCapture.on("recording-error", (error: Error) => {
|
||||
console.error("Main: Received recording error from AudioCapture:", error);
|
||||
});
|
||||
|
||||
// Handle individual audio chunks for real-time transcription
|
||||
audioCapture.on('chunk-ready', async (chunkData: ChunkData) => {
|
||||
logger.audio.info('Received chunk for transcription', {
|
||||
audioCapture.on("chunk-ready", async (chunkData: ChunkData) => {
|
||||
logger.audio.info("Received chunk for transcription", {
|
||||
sessionId: chunkData.sessionId,
|
||||
chunkId: chunkData.chunkId,
|
||||
audioDataSize: chunkData.audioData.length,
|
||||
|
|
@ -358,18 +393,27 @@ app.on('ready', async () => {
|
|||
|
||||
try {
|
||||
// Get or create transcription session for this recording session
|
||||
let transcriptionSession = activeTranscriptionSessions.get(chunkData.sessionId);
|
||||
let transcriptionSession = activeTranscriptionSessions.get(
|
||||
chunkData.sessionId,
|
||||
);
|
||||
|
||||
if (!transcriptionSession) {
|
||||
// Create new transcription session
|
||||
const transcriptionClient = contextualTranscriptionManager!.createDefaultClient();
|
||||
const transcriptionClient =
|
||||
contextualTranscriptionManager!.createDefaultClient();
|
||||
|
||||
transcriptionSession = new TranscriptionSession(chunkData.sessionId, transcriptionClient);
|
||||
activeTranscriptionSessions.set(chunkData.sessionId, transcriptionSession);
|
||||
transcriptionSession = new TranscriptionSession(
|
||||
chunkData.sessionId,
|
||||
transcriptionClient,
|
||||
);
|
||||
activeTranscriptionSessions.set(
|
||||
chunkData.sessionId,
|
||||
transcriptionSession,
|
||||
);
|
||||
|
||||
// Set up session event handlers
|
||||
transcriptionSession.on('chunk-completed', (result) => {
|
||||
logger.ai.info('Chunk transcription completed', {
|
||||
transcriptionSession.on("chunk-completed", (result) => {
|
||||
logger.ai.info("Chunk transcription completed", {
|
||||
sessionId: chunkData.sessionId,
|
||||
chunkId: result.chunkId,
|
||||
textLength: result.text.length,
|
||||
|
|
@ -377,8 +421,8 @@ app.on('ready', async () => {
|
|||
});
|
||||
});
|
||||
|
||||
transcriptionSession.on('session-completed', (sessionResult) => {
|
||||
logger.ai.info('Transcription session completed', {
|
||||
transcriptionSession.on("session-completed", (sessionResult) => {
|
||||
logger.ai.info("Transcription session completed", {
|
||||
sessionId: sessionResult.sessionId,
|
||||
finalTextLength: sessionResult.finalText.length,
|
||||
totalChunks: sessionResult.chunkResults.length,
|
||||
|
|
@ -386,21 +430,29 @@ app.on('ready', async () => {
|
|||
});
|
||||
|
||||
// Paste the final result to active application
|
||||
if (sessionResult.finalText && sessionResult.finalText.trim().length > 0) {
|
||||
logger.main.info('Final transcription pasted to active application', {
|
||||
textLength: sessionResult.finalText.length,
|
||||
if (
|
||||
sessionResult.finalText &&
|
||||
sessionResult.finalText.trim().length > 0
|
||||
) {
|
||||
logger.main.info(
|
||||
"Final transcription pasted to active application",
|
||||
{
|
||||
textLength: sessionResult.finalText.length,
|
||||
},
|
||||
);
|
||||
swiftIOBridgeClientInstance!.call("pasteText", {
|
||||
transcript: sessionResult.finalText,
|
||||
});
|
||||
swiftIOBridgeClientInstance!.call('pasteText', { transcript: sessionResult.finalText });
|
||||
} else {
|
||||
logger.main.warn('Final transcription was empty, not pasting');
|
||||
logger.main.warn("Final transcription was empty, not pasting");
|
||||
}
|
||||
|
||||
// Clean up completed session
|
||||
activeTranscriptionSessions.delete(chunkData.sessionId);
|
||||
});
|
||||
|
||||
transcriptionSession.on('chunk-error', (errorInfo) => {
|
||||
logger.ai.error('Chunk transcription error', {
|
||||
transcriptionSession.on("chunk-error", (errorInfo) => {
|
||||
logger.ai.error("Chunk transcription error", {
|
||||
sessionId: chunkData.sessionId,
|
||||
chunkId: errorInfo.chunkId,
|
||||
error: errorInfo.error,
|
||||
|
|
@ -408,13 +460,15 @@ app.on('ready', async () => {
|
|||
// Continue processing other chunks even if one fails
|
||||
});
|
||||
|
||||
logger.ai.info('Created new transcription session', { sessionId: chunkData.sessionId });
|
||||
logger.ai.info("Created new transcription session", {
|
||||
sessionId: chunkData.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Add chunk to session for processing
|
||||
transcriptionSession.addChunk(chunkData);
|
||||
} catch (error) {
|
||||
logger.ai.error('Error handling chunk-ready event', {
|
||||
logger.ai.error("Error handling chunk-ready event", {
|
||||
sessionId: chunkData.sessionId,
|
||||
chunkId: chunkData.chunkId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
|
@ -423,42 +477,47 @@ app.on('ready', async () => {
|
|||
});
|
||||
|
||||
// Handle audio data chunks from renderer
|
||||
ipcMain.handle('audio-data-chunk', (event, chunk: ArrayBuffer, isFinalChunk: boolean) => {
|
||||
if (chunk instanceof ArrayBuffer) {
|
||||
console.log(
|
||||
`Main: IPC received audio-data-chunk (ArrayBuffer) of size: ${chunk.byteLength} bytes. isFinalChunk: ${isFinalChunk}`
|
||||
);
|
||||
const buffer = Buffer.from(chunk);
|
||||
if (buffer.length === 0) {
|
||||
console.warn('Main: Received an empty audio chunk after conversion.');
|
||||
ipcMain.handle(
|
||||
"audio-data-chunk",
|
||||
(event, chunk: ArrayBuffer, isFinalChunk: boolean) => {
|
||||
if (chunk instanceof ArrayBuffer) {
|
||||
console.log(
|
||||
`Main: IPC received audio-data-chunk (ArrayBuffer) of size: ${chunk.byteLength} bytes. isFinalChunk: ${isFinalChunk}`,
|
||||
);
|
||||
const buffer = Buffer.from(chunk);
|
||||
if (buffer.length === 0) {
|
||||
console.warn("Main: Received an empty audio chunk after conversion.");
|
||||
}
|
||||
// The AudioCapture class will now need to handle buffering and the isFinalChunk flag
|
||||
audioCapture?.handleAudioChunk(buffer, isFinalChunk);
|
||||
} else {
|
||||
console.error(
|
||||
"Main: Received audio chunk, but it is not an ArrayBuffer. Type:",
|
||||
typeof chunk,
|
||||
);
|
||||
throw new Error("Invalid audio chunk type received.");
|
||||
}
|
||||
// The AudioCapture class will now need to handle buffering and the isFinalChunk flag
|
||||
audioCapture?.handleAudioChunk(buffer, isFinalChunk);
|
||||
} else {
|
||||
console.error(
|
||||
'Main: Received audio chunk, but it is not an ArrayBuffer. Type:',
|
||||
typeof chunk
|
||||
);
|
||||
throw new Error('Invalid audio chunk type received.');
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle('recording-starting', async () => {
|
||||
console.log('Main: Received recording-starting event.');
|
||||
ipcMain.handle("recording-starting", async () => {
|
||||
console.log("Main: Received recording-starting event.");
|
||||
|
||||
// Preload the transcription model for fast processing
|
||||
try {
|
||||
if (contextualTranscriptionManager) {
|
||||
if (!contextualTranscriptionManager.isModelLoaded()) {
|
||||
logger.ai.info('Preloading transcription model for recording session');
|
||||
logger.ai.info(
|
||||
"Preloading transcription model for recording session",
|
||||
);
|
||||
await contextualTranscriptionManager.preloadModel();
|
||||
logger.ai.info('Transcription model preloaded successfully');
|
||||
logger.ai.info("Transcription model preloaded successfully");
|
||||
} else {
|
||||
logger.ai.info('Transcription model already loaded');
|
||||
logger.ai.info("Transcription model already loaded");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.ai.error('Error preloading transcription model', {
|
||||
logger.ai.error("Error preloading transcription model", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
|
@ -468,52 +527,56 @@ app.on('ready', async () => {
|
|||
//const accessibilityContext = await swiftIOBridgeClientInstance!.call('getAccessibilityContext', { editableOnly: true });
|
||||
//console.log('Main: Accessibility context captured:', JSON.stringify(accessibilityContext, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Main: Error getting accessibility context:', error);
|
||||
console.error("Main: Error getting accessibility context:", error);
|
||||
}
|
||||
|
||||
await swiftIOBridgeClientInstance!.call('muteSystemAudio', {});
|
||||
await swiftIOBridgeClientInstance!.call("muteSystemAudio", {});
|
||||
});
|
||||
|
||||
ipcMain.handle('recording-stopping', async () => {
|
||||
console.log('Main: Received recording-stopping event.');
|
||||
await swiftIOBridgeClientInstance!.call('restoreSystemAudio', {});
|
||||
ipcMain.handle("recording-stopping", async () => {
|
||||
console.log("Main: Received recording-stopping event.");
|
||||
await swiftIOBridgeClientInstance!.call("restoreSystemAudio", {});
|
||||
});
|
||||
|
||||
|
||||
// Initialize the SwiftIOBridgeClient
|
||||
swiftIOBridgeClientInstance = new SwiftIOBridge();
|
||||
|
||||
swiftIOBridgeClientInstance.on('helperEvent', (event: HelperEvent) => {
|
||||
logger.swift.debug('Received helperEvent from SwiftIOBridge', { event });
|
||||
swiftIOBridgeClientInstance.on("helperEvent", (event: HelperEvent) => {
|
||||
logger.swift.debug("Received helperEvent from SwiftIOBridge", { event });
|
||||
|
||||
switch (event.type) {
|
||||
case 'flagsChanged': {
|
||||
case "flagsChanged": {
|
||||
const payload = event.payload;
|
||||
logger.swift.debug('Received flagsChanged event', {
|
||||
logger.swift.debug("Received flagsChanged event", {
|
||||
fnKeyPressed: payload?.fnKeyPressed,
|
||||
});
|
||||
// Use flagsChanged for more reliable Fn key state tracking
|
||||
if (payload?.fnKeyPressed !== undefined) {
|
||||
logger.swift.info('Setting recording state', { state: payload.fnKeyPressed });
|
||||
floatingButtonWindow!.webContents.send('recording-state-changed', payload.fnKeyPressed);
|
||||
logger.swift.info("Setting recording state", {
|
||||
state: payload.fnKeyPressed,
|
||||
});
|
||||
floatingButtonWindow!.webContents.send(
|
||||
"recording-state-changed",
|
||||
payload.fnKeyPressed,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'keyDown': {
|
||||
case "keyDown": {
|
||||
const payload = event.payload;
|
||||
// console.log(`Main: Received keyDown for key: ${payload?.key}.`);
|
||||
// Keep keyDown handling as fallback, but flagsChanged should be primary
|
||||
if (payload?.key?.toLowerCase() === 'fn') {
|
||||
if (payload?.key?.toLowerCase() === "fn") {
|
||||
// console.log('Main: Fn keyDown detected (fallback)');
|
||||
// Don't send recording-state-changed here as flagsChanged should handle it
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'keyUp': {
|
||||
case "keyUp": {
|
||||
const payload = event.payload;
|
||||
// console.log(`Main: Received keyUp for key: ${payload?.key}.`);
|
||||
// Keep keyUp handling as fallback, but flagsChanged should be primary
|
||||
if (payload?.key?.toLowerCase() === 'fn') {
|
||||
if (payload?.key?.toLowerCase() === "fn") {
|
||||
// console.log('Main: Fn keyUp detected (fallback)');
|
||||
// Don't send recording-state-changed here as flagsChanged should handle it
|
||||
}
|
||||
|
|
@ -526,13 +589,16 @@ app.on('ready', async () => {
|
|||
}
|
||||
});
|
||||
|
||||
swiftIOBridgeClientInstance.on('error', (error) => {
|
||||
logError(error instanceof Error ? error : new Error(String(error)), 'SwiftIOBridge error');
|
||||
swiftIOBridgeClientInstance.on("error", (error) => {
|
||||
logError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
"SwiftIOBridge error",
|
||||
);
|
||||
// Potentially notify the user or attempt to restart
|
||||
});
|
||||
|
||||
swiftIOBridgeClientInstance.on('close', (code) => {
|
||||
logger.swift.warn('Swift helper process closed', { code });
|
||||
swiftIOBridgeClientInstance.on("close", (code) => {
|
||||
logger.swift.warn("Swift helper process closed", { code });
|
||||
// Handle unexpected close, maybe attempt restart
|
||||
});
|
||||
|
||||
|
|
@ -542,64 +608,84 @@ app.on('ready', async () => {
|
|||
}
|
||||
});
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
if (process.platform === "darwin") {
|
||||
try {
|
||||
console.log('Main: Setting up display change notifications');
|
||||
console.log("Main: Setting up display change notifications");
|
||||
|
||||
activeSpaceChangeSubscriptionId = systemPreferences.subscribeWorkspaceNotification(
|
||||
'NSWorkspaceActiveDisplayDidChangeNotification',
|
||||
() => {
|
||||
if (floatingButtonWindow && !floatingButtonWindow.isDestroyed()) {
|
||||
try {
|
||||
const cursorPoint = screen.getCursorScreenPoint();
|
||||
const displayForCursor = screen.getDisplayNearestPoint(cursorPoint);
|
||||
if (currentWindowDisplayId !== displayForCursor.id) {
|
||||
console.log(
|
||||
`[Main Process] Moving floating window to display ID: ${displayForCursor.id}`
|
||||
activeSpaceChangeSubscriptionId =
|
||||
systemPreferences.subscribeWorkspaceNotification(
|
||||
"NSWorkspaceActiveDisplayDidChangeNotification",
|
||||
() => {
|
||||
if (floatingButtonWindow && !floatingButtonWindow.isDestroyed()) {
|
||||
try {
|
||||
const cursorPoint = screen.getCursorScreenPoint();
|
||||
const displayForCursor =
|
||||
screen.getDisplayNearestPoint(cursorPoint);
|
||||
if (currentWindowDisplayId !== displayForCursor.id) {
|
||||
console.log(
|
||||
`[Main Process] Moving floating window to display ID: ${displayForCursor.id}`,
|
||||
);
|
||||
floatingButtonWindow.setBounds(displayForCursor.workArea);
|
||||
currentWindowDisplayId = displayForCursor.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[Main Process] Error handling display change:",
|
||||
error,
|
||||
);
|
||||
floatingButtonWindow.setBounds(displayForCursor.workArea);
|
||||
currentWindowDisplayId = displayForCursor.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Main Process] Error handling display change:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (activeSpaceChangeSubscriptionId !== undefined && activeSpaceChangeSubscriptionId >= 0) {
|
||||
console.log(`Main: Successfully subscribed to display change notifications`);
|
||||
if (
|
||||
activeSpaceChangeSubscriptionId !== undefined &&
|
||||
activeSpaceChangeSubscriptionId >= 0
|
||||
) {
|
||||
console.log(
|
||||
`Main: Successfully subscribed to display change notifications`,
|
||||
);
|
||||
} else {
|
||||
console.error('Main: Failed to subscribe to display change notifications');
|
||||
console.error(
|
||||
"Main: Failed to subscribe to display change notifications",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Main: Error during subscription to display notifications:', e);
|
||||
console.error(
|
||||
"Main: Error during subscription to display notifications:",
|
||||
e,
|
||||
);
|
||||
activeSpaceChangeSubscriptionId = null;
|
||||
}
|
||||
} else {
|
||||
console.log('Main: Display change tracking is a macOS-only feature');
|
||||
console.log("Main: Display change tracking is a macOS-only feature");
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up intervals and subscriptions
|
||||
app.on('will-quit', () => {
|
||||
app.on("will-quit", () => {
|
||||
// globalShortcut.unregisterAll();
|
||||
globalShortcut.unregisterAll();
|
||||
if (swiftIOBridgeClientInstance) {
|
||||
console.log('Main: Stopping Swift helper...');
|
||||
console.log("Main: Stopping Swift helper...");
|
||||
swiftIOBridgeClientInstance.stopHelper();
|
||||
}
|
||||
if (modelManagerService) {
|
||||
console.log('Main: Cleaning up model downloads...');
|
||||
console.log("Main: Cleaning up model downloads...");
|
||||
modelManagerService.cleanup();
|
||||
}
|
||||
if (contextualTranscriptionManager) {
|
||||
console.log('Main: Cleaning up transcription models...');
|
||||
console.log("Main: Cleaning up transcription models...");
|
||||
contextualTranscriptionManager.dispose();
|
||||
}
|
||||
if (process.platform === 'darwin' && activeSpaceChangeSubscriptionId !== null) {
|
||||
systemPreferences.unsubscribeWorkspaceNotification(activeSpaceChangeSubscriptionId);
|
||||
console.log('Main: Unsubscribed from display change notifications');
|
||||
if (
|
||||
process.platform === "darwin" &&
|
||||
activeSpaceChangeSubscriptionId !== null
|
||||
) {
|
||||
systemPreferences.unsubscribeWorkspaceNotification(
|
||||
activeSpaceChangeSubscriptionId,
|
||||
);
|
||||
console.log("Main: Unsubscribed from display change notifications");
|
||||
activeSpaceChangeSubscriptionId = null;
|
||||
}
|
||||
});
|
||||
|
|
@ -607,13 +693,13 @@ app.on('will-quit', () => {
|
|||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
app.on("activate", () => {
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
|
|
@ -637,19 +723,25 @@ app.on('activate', () => {
|
|||
|
||||
// Function to log the accessibility tree (added)
|
||||
async function logAccessibilityTree() {
|
||||
if (swiftIOBridgeClientInstance && swiftIOBridgeClientInstance.isHelperRunning()) {
|
||||
if (
|
||||
swiftIOBridgeClientInstance &&
|
||||
swiftIOBridgeClientInstance.isHelperRunning()
|
||||
) {
|
||||
try {
|
||||
// console.log('Main: Requesting full accessibility tree...');
|
||||
// Call with empty params for the whole tree, as per schema for GetAccessibilityTreeDetailsParams
|
||||
const result = await swiftIOBridgeClientInstance.call('getAccessibilityTreeDetails', {});
|
||||
const result = await swiftIOBridgeClientInstance.call(
|
||||
"getAccessibilityTreeDetails",
|
||||
{},
|
||||
);
|
||||
// Using JSON.stringify to see the whole structure since it's 'any' for now
|
||||
// console.log('Main: Accessibility tree received:', JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Main: Error calling getAccessibilityTreeDetails:', error);
|
||||
console.error("Main: Error calling getAccessibilityTreeDetails:", error);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
'Main: SwiftIOBridge not ready or helper not running, cannot log accessibility tree.'
|
||||
"Main: SwiftIOBridge not ready or helper not running, cannot log accessibility tree.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,130 +1,143 @@
|
|||
import { app, Menu, MenuItemConstructorOptions, BrowserWindow } from 'electron';
|
||||
import { app, Menu, MenuItemConstructorOptions, BrowserWindow } from "electron";
|
||||
|
||||
// Forward declaration or import of the function type if it's complex
|
||||
// For simplicity, we assume createOrShowSettingsWindow is a () => void function
|
||||
|
||||
export const setupApplicationMenu = (
|
||||
createOrShowSettingsWindow: () => void,
|
||||
checkForUpdates?: () => void
|
||||
checkForUpdates?: () => void,
|
||||
) => {
|
||||
const menuTemplate: MenuItemConstructorOptions[] = [
|
||||
// { role: 'appMenu' } for macOS
|
||||
...(process.platform === 'darwin'
|
||||
...(process.platform === "darwin"
|
||||
? ([
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: 'about' as const },
|
||||
{ type: 'separator' as const },
|
||||
...(checkForUpdates ? [{
|
||||
label: 'Check for Updates...',
|
||||
click: () => checkForUpdates(),
|
||||
} as MenuItemConstructorOptions, { type: 'separator' as const }] : []),
|
||||
{ role: "about" as const },
|
||||
{ type: "separator" as const },
|
||||
...(checkForUpdates
|
||||
? [
|
||||
{
|
||||
label: "Check for Updates...",
|
||||
click: () => checkForUpdates(),
|
||||
} as MenuItemConstructorOptions,
|
||||
{ type: "separator" as const },
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Settings',
|
||||
accelerator: 'CmdOrCtrl+,',
|
||||
label: "Settings",
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => createOrShowSettingsWindow(),
|
||||
},
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'services' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'hide' as const },
|
||||
{ role: 'hideOthers' as const },
|
||||
{ role: 'unhide' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'quit' as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "services" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "hide" as const },
|
||||
{ role: "hideOthers" as const },
|
||||
{ role: "unhide" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "quit" as const },
|
||||
],
|
||||
},
|
||||
] as MenuItemConstructorOptions[])
|
||||
: []),
|
||||
// { role: 'fileMenu' } for Windows/Linux
|
||||
...(process.platform !== 'darwin'
|
||||
...(process.platform !== "darwin"
|
||||
? ([
|
||||
{
|
||||
label: 'File',
|
||||
label: "File",
|
||||
submenu: [
|
||||
{
|
||||
label: 'Settings',
|
||||
accelerator: 'CmdOrCtrl+,',
|
||||
label: "Settings",
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => createOrShowSettingsWindow(),
|
||||
},
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'quit' as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "quit" as const },
|
||||
],
|
||||
},
|
||||
] as MenuItemConstructorOptions[])
|
||||
: []),
|
||||
// { role: 'editMenu' }
|
||||
{
|
||||
label: 'Edit',
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: 'undo' as const },
|
||||
{ role: 'redo' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'cut' as const },
|
||||
{ role: 'copy' as const },
|
||||
{ role: 'paste' as const },
|
||||
...(process.platform === 'darwin'
|
||||
{ role: "undo" as const },
|
||||
{ role: "redo" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "cut" as const },
|
||||
{ role: "copy" as const },
|
||||
{ role: "paste" as const },
|
||||
...(process.platform === "darwin"
|
||||
? [
|
||||
{ role: 'pasteAndMatchStyle' as const },
|
||||
{ role: 'delete' as const },
|
||||
{ role: 'selectAll' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: "pasteAndMatchStyle" as const },
|
||||
{ role: "delete" as const },
|
||||
{ role: "selectAll" as const },
|
||||
{ type: "separator" as const },
|
||||
{
|
||||
label: 'Speech',
|
||||
submenu: [{ role: 'startSpeaking' as const }, { role: 'stopSpeaking' as const }],
|
||||
label: "Speech",
|
||||
submenu: [
|
||||
{ role: "startSpeaking" as const },
|
||||
{ role: "stopSpeaking" as const },
|
||||
],
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ role: 'delete' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'selectAll' as const },
|
||||
{ role: "delete" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "selectAll" as const },
|
||||
]),
|
||||
],
|
||||
},
|
||||
// { role: 'viewMenu' }
|
||||
{
|
||||
label: 'View',
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: 'reload' as const },
|
||||
{ role: 'forceReload' as const },
|
||||
{ role: 'toggleDevTools' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'resetZoom' as const },
|
||||
{ role: 'zoomIn' as const },
|
||||
{ role: 'zoomOut' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'togglefullscreen' as const },
|
||||
{ role: "reload" as const },
|
||||
{ role: "forceReload" as const },
|
||||
{ role: "toggleDevTools" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "resetZoom" as const },
|
||||
{ role: "zoomIn" as const },
|
||||
{ role: "zoomOut" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "togglefullscreen" as const },
|
||||
],
|
||||
},
|
||||
// { role: 'windowMenu' }
|
||||
{
|
||||
label: 'Window',
|
||||
label: "Window",
|
||||
submenu: [
|
||||
{ role: 'minimize' as const },
|
||||
{ role: 'zoom' as const },
|
||||
...(process.platform === 'darwin'
|
||||
{ role: "minimize" as const },
|
||||
{ role: "zoom" as const },
|
||||
...(process.platform === "darwin"
|
||||
? [
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'front' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'window' as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "front" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "window" as const },
|
||||
]
|
||||
: [{ role: 'close' as const }]),
|
||||
: [{ role: "close" as const }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'help' as const,
|
||||
role: "help" as const,
|
||||
submenu: [
|
||||
...(checkForUpdates ? [{
|
||||
label: 'Check for Updates...',
|
||||
click: () => checkForUpdates(),
|
||||
} as MenuItemConstructorOptions, { type: 'separator' as const }] : []),
|
||||
...(checkForUpdates
|
||||
? [
|
||||
{
|
||||
label: "Check for Updates...",
|
||||
click: () => checkForUpdates(),
|
||||
} as MenuItemConstructorOptions,
|
||||
{ type: "separator" as const },
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Learn More',
|
||||
label: "Learn More",
|
||||
click: async () => {
|
||||
const { shell } = await import('electron');
|
||||
shell.openExternal('https://electronjs.org');
|
||||
const { shell } = await import("electron");
|
||||
shell.openExternal("https://electronjs.org");
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
// See the Electron documentation for details on how to use preload scripts:
|
||||
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
|
||||
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import log from 'electron-log/renderer';
|
||||
import { exposeElectronTRPC } from 'electron-trpc-experimental/preload';
|
||||
import type { ElectronAPI } from '../types/electron-api';
|
||||
import type { FormatterConfig } from '../modules/formatter';
|
||||
import type { Transcription, NewTranscription } from '../db/schema';
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron";
|
||||
import log from "electron-log/renderer";
|
||||
import { exposeElectronTRPC } from "electron-trpc-experimental/preload";
|
||||
import type { ElectronAPI } from "../types/electron-api";
|
||||
import type { FormatterConfig } from "../modules/formatter";
|
||||
import type { Transcription, NewTranscription } from "../db/schema";
|
||||
|
||||
interface ShortcutData {
|
||||
shortcut: string;
|
||||
|
|
@ -14,39 +14,47 @@ interface ShortcutData {
|
|||
}
|
||||
|
||||
const api: ElectronAPI = {
|
||||
onRecordingStarting: async () => await ipcRenderer.invoke('recording-starting'),
|
||||
onRecordingStopping: async () => await ipcRenderer.invoke('recording-stopping'),
|
||||
sendAudioChunk: (chunk: ArrayBuffer, isFinalChunk: boolean = false): Promise<void> =>
|
||||
ipcRenderer.invoke('audio-data-chunk', chunk, isFinalChunk),
|
||||
onRecordingStarting: async () =>
|
||||
await ipcRenderer.invoke("recording-starting"),
|
||||
onRecordingStopping: async () =>
|
||||
await ipcRenderer.invoke("recording-stopping"),
|
||||
sendAudioChunk: (
|
||||
chunk: ArrayBuffer,
|
||||
isFinalChunk: boolean = false,
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke("audio-data-chunk", chunk, isFinalChunk),
|
||||
|
||||
onRecordingStateChanged: (callback: (newState: boolean) => void) => {
|
||||
const handler = (_event: IpcRendererEvent, newState: boolean) => callback(newState);
|
||||
ipcRenderer.on('recording-state-changed', handler);
|
||||
const handler = (_event: IpcRendererEvent, newState: boolean) =>
|
||||
callback(newState);
|
||||
ipcRenderer.on("recording-state-changed", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener('recording-state-changed', handler);
|
||||
ipcRenderer.removeListener("recording-state-changed", handler);
|
||||
};
|
||||
},
|
||||
// Switched to invoke/handle for request-response
|
||||
onGlobalShortcut: (callback: (data: ShortcutData) => void) => {
|
||||
const handler = (_event: IpcRendererEvent, data: ShortcutData) => callback(data);
|
||||
ipcRenderer.on('global-shortcut-event', handler);
|
||||
const handler = (_event: IpcRendererEvent, data: ShortcutData) =>
|
||||
callback(data);
|
||||
ipcRenderer.on("global-shortcut-event", handler);
|
||||
// Optional: Return a cleanup function to remove the listener
|
||||
return () => {
|
||||
ipcRenderer.removeListener('global-shortcut-event', handler);
|
||||
ipcRenderer.removeListener("global-shortcut-event", handler);
|
||||
};
|
||||
},
|
||||
onKeyEvent: (callback: (keyEvent: unknown) => void) => {
|
||||
const handler = (_event: IpcRendererEvent, keyEvent: unknown) => callback(keyEvent);
|
||||
ipcRenderer.on('key-event', handler);
|
||||
const handler = (_event: IpcRendererEvent, keyEvent: unknown) =>
|
||||
callback(keyEvent);
|
||||
ipcRenderer.on("key-event", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener('key-event', handler);
|
||||
ipcRenderer.removeListener("key-event", handler);
|
||||
};
|
||||
},
|
||||
onForceStopMediaRecorder: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on('force-stop-mediarecorder', handler);
|
||||
ipcRenderer.on("force-stop-mediarecorder", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener('force-stop-mediarecorder', handler);
|
||||
ipcRenderer.removeListener("force-stop-mediarecorder", handler);
|
||||
};
|
||||
},
|
||||
// If you want a way to remove all listeners for this event from renderer:
|
||||
|
|
@ -55,52 +63,63 @@ const api: ElectronAPI = {
|
|||
// }
|
||||
|
||||
// Model Management API
|
||||
getAvailableModels: () => ipcRenderer.invoke('get-available-models'),
|
||||
getDownloadedModels: () => ipcRenderer.invoke('get-downloaded-models'),
|
||||
isModelDownloaded: (modelId: string) => ipcRenderer.invoke('is-model-downloaded', modelId),
|
||||
getDownloadProgress: (modelId: string) => ipcRenderer.invoke('get-download-progress', modelId),
|
||||
getActiveDownloads: () => ipcRenderer.invoke('get-active-downloads'),
|
||||
downloadModel: (modelId: string) => ipcRenderer.invoke('download-model', modelId),
|
||||
cancelDownload: (modelId: string) => ipcRenderer.invoke('cancel-download', modelId),
|
||||
deleteModel: (modelId: string) => ipcRenderer.invoke('delete-model', modelId),
|
||||
getModelsDirectory: () => ipcRenderer.invoke('get-models-directory'),
|
||||
getAvailableModels: () => ipcRenderer.invoke("get-available-models"),
|
||||
getDownloadedModels: () => ipcRenderer.invoke("get-downloaded-models"),
|
||||
isModelDownloaded: (modelId: string) =>
|
||||
ipcRenderer.invoke("is-model-downloaded", modelId),
|
||||
getDownloadProgress: (modelId: string) =>
|
||||
ipcRenderer.invoke("get-download-progress", modelId),
|
||||
getActiveDownloads: () => ipcRenderer.invoke("get-active-downloads"),
|
||||
downloadModel: (modelId: string) =>
|
||||
ipcRenderer.invoke("download-model", modelId),
|
||||
cancelDownload: (modelId: string) =>
|
||||
ipcRenderer.invoke("cancel-download", modelId),
|
||||
deleteModel: (modelId: string) => ipcRenderer.invoke("delete-model", modelId),
|
||||
getModelsDirectory: () => ipcRenderer.invoke("get-models-directory"),
|
||||
|
||||
// Local Whisper API
|
||||
isLocalWhisperAvailable: () => ipcRenderer.invoke('is-local-whisper-available'),
|
||||
getLocalWhisperModels: () => ipcRenderer.invoke('get-local-whisper-models'),
|
||||
getSelectedModel: () => ipcRenderer.invoke('get-selected-model'),
|
||||
setSelectedModel: (modelId: string) => ipcRenderer.invoke('set-selected-model', modelId),
|
||||
isLocalWhisperAvailable: () =>
|
||||
ipcRenderer.invoke("is-local-whisper-available"),
|
||||
getLocalWhisperModels: () => ipcRenderer.invoke("get-local-whisper-models"),
|
||||
getSelectedModel: () => ipcRenderer.invoke("get-selected-model"),
|
||||
setSelectedModel: (modelId: string) =>
|
||||
ipcRenderer.invoke("set-selected-model", modelId),
|
||||
setWhisperExecutablePath: (path: string) =>
|
||||
ipcRenderer.invoke('set-whisper-executable-path', path),
|
||||
ipcRenderer.invoke("set-whisper-executable-path", path),
|
||||
|
||||
// Formatter Configuration API
|
||||
getFormatterConfig: () => ipcRenderer.invoke('get-formatter-config'),
|
||||
getFormatterConfig: () => ipcRenderer.invoke("get-formatter-config"),
|
||||
setFormatterConfig: (config: FormatterConfig) =>
|
||||
ipcRenderer.invoke('set-formatter-config', config),
|
||||
ipcRenderer.invoke("set-formatter-config", config),
|
||||
|
||||
// Transcription Database API
|
||||
getTranscriptions: (options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: 'timestamp' | 'createdAt';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
sortBy?: "timestamp" | "createdAt";
|
||||
sortOrder?: "asc" | "desc";
|
||||
search?: string;
|
||||
}) => ipcRenderer.invoke('get-transcriptions', options),
|
||||
getTranscriptionById: (id: number) => ipcRenderer.invoke('get-transcription-by-id', id),
|
||||
createTranscription: (data: Omit<NewTranscription, 'id' | 'createdAt' | 'updatedAt'>) =>
|
||||
ipcRenderer.invoke('create-transcription', data),
|
||||
updateTranscription: (id: number, data: Partial<Omit<Transcription, 'id' | 'createdAt'>>) =>
|
||||
ipcRenderer.invoke('update-transcription', id, data),
|
||||
deleteTranscription: (id: number) => ipcRenderer.invoke('delete-transcription', id),
|
||||
}) => ipcRenderer.invoke("get-transcriptions", options),
|
||||
getTranscriptionById: (id: number) =>
|
||||
ipcRenderer.invoke("get-transcription-by-id", id),
|
||||
createTranscription: (
|
||||
data: Omit<NewTranscription, "id" | "createdAt" | "updatedAt">,
|
||||
) => ipcRenderer.invoke("create-transcription", data),
|
||||
updateTranscription: (
|
||||
id: number,
|
||||
data: Partial<Omit<Transcription, "id" | "createdAt">>,
|
||||
) => ipcRenderer.invoke("update-transcription", id, data),
|
||||
deleteTranscription: (id: number) =>
|
||||
ipcRenderer.invoke("delete-transcription", id),
|
||||
getTranscriptionsCount: (search?: string) =>
|
||||
ipcRenderer.invoke('get-transcriptions-count', search),
|
||||
ipcRenderer.invoke("get-transcriptions-count", search),
|
||||
searchTranscriptions: (searchTerm: string, limit?: number) =>
|
||||
ipcRenderer.invoke('search-transcriptions', searchTerm, limit),
|
||||
|
||||
ipcRenderer.invoke("search-transcriptions", searchTerm, limit),
|
||||
|
||||
// Vocabulary Database API
|
||||
on: (channel: string, callback: (...args: any[]) => void) => {
|
||||
const handler = (_event: IpcRendererEvent, ...args: any[]) => callback(...args);
|
||||
const handler = (_event: IpcRendererEvent, ...args: any[]) =>
|
||||
callback(...args);
|
||||
ipcRenderer.on(channel, handler);
|
||||
// Store the handler mapping for proper cleanup
|
||||
if (!(window as any).__electronEventHandlers) {
|
||||
|
|
@ -109,7 +128,9 @@ const api: ElectronAPI = {
|
|||
if (!(window as any).__electronEventHandlers.has(channel)) {
|
||||
(window as any).__electronEventHandlers.set(channel, []);
|
||||
}
|
||||
(window as any).__electronEventHandlers.get(channel).push({ original: callback, handler });
|
||||
(window as any).__electronEventHandlers
|
||||
.get(channel)
|
||||
.push({ original: callback, handler });
|
||||
},
|
||||
off: (channel: string, callback: (...args: any[]) => void) => {
|
||||
if (
|
||||
|
|
@ -136,9 +157,9 @@ const api: ElectronAPI = {
|
|||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', api);
|
||||
contextBridge.exposeInMainWorld("electronAPI", api);
|
||||
|
||||
// Expose tRPC for electron-trpc-experimental
|
||||
process.once('loaded', async () => {
|
||||
process.once("loaded", async () => {
|
||||
exposeElectronTRPC();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { autoUpdater } from 'electron-updater';
|
||||
import { app, dialog, BrowserWindow } from 'electron';
|
||||
import { EventEmitter } from 'events';
|
||||
import { logger } from '../logger';
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { app, dialog, BrowserWindow } from "electron";
|
||||
import { EventEmitter } from "events";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export class AutoUpdaterService extends EventEmitter {
|
||||
private checkingForUpdate = false;
|
||||
|
|
@ -10,12 +10,12 @@ export class AutoUpdaterService extends EventEmitter {
|
|||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
|
||||
// Only set up auto-updater in production
|
||||
if (process.env.NODE_ENV !== 'development' && app.isPackaged) {
|
||||
if (process.env.NODE_ENV !== "development" && app.isPackaged) {
|
||||
this.setupAutoUpdater();
|
||||
} else {
|
||||
logger.updater.info('Auto-updater disabled in development mode');
|
||||
logger.updater.info("Auto-updater disabled in development mode");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -29,20 +29,20 @@ export class AutoUpdaterService extends EventEmitter {
|
|||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Development settings
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
// In development, you can test with a local update server
|
||||
// autoUpdater.updateConfigPath = path.join(__dirname, 'dev-app-update.yml');
|
||||
autoUpdater.forceDevUpdateConfig = true;
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
logger.updater.info('Checking for update...');
|
||||
autoUpdater.on("checking-for-update", () => {
|
||||
logger.updater.info("Checking for update...");
|
||||
this.checkingForUpdate = true;
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
logger.updater.info('Update available', {
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
logger.updater.info("Update available", {
|
||||
version: info.version,
|
||||
releaseDate: info.releaseDate,
|
||||
});
|
||||
|
|
@ -51,36 +51,39 @@ export class AutoUpdaterService extends EventEmitter {
|
|||
this.showUpdateDialog(info);
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
logger.updater.info('Update not available', { version: info.version });
|
||||
autoUpdater.on("update-not-available", (info) => {
|
||||
logger.updater.info("Update not available", { version: info.version });
|
||||
this.checkingForUpdate = false;
|
||||
this.updateAvailable = false;
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
logger.updater.error('Error in auto-updater', { error: err.message });
|
||||
autoUpdater.on("error", (err) => {
|
||||
logger.updater.error("Error in auto-updater", { error: err.message });
|
||||
this.checkingForUpdate = false;
|
||||
|
||||
|
||||
// Show error dialog only if user manually checked for updates
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
dialog.showErrorBox('Update Error', `Error checking for updates: ${err.message}`);
|
||||
dialog.showErrorBox(
|
||||
"Update Error",
|
||||
`Error checking for updates: ${err.message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progressObj) => {
|
||||
logger.updater.info('Download progress', {
|
||||
autoUpdater.on("download-progress", (progressObj) => {
|
||||
logger.updater.info("Download progress", {
|
||||
bytesPerSecond: progressObj.bytesPerSecond,
|
||||
percent: progressObj.percent,
|
||||
transferred: progressObj.transferred,
|
||||
total: progressObj.total,
|
||||
});
|
||||
|
||||
|
||||
// Emit event for tRPC subscription
|
||||
this.emit('download-progress', progressObj);
|
||||
this.emit("download-progress", progressObj);
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
logger.updater.info('Update downloaded', { version: info.version });
|
||||
autoUpdater.on("update-downloaded", (info) => {
|
||||
logger.updater.info("Update downloaded", { version: info.version });
|
||||
this.showInstallDialog(info);
|
||||
});
|
||||
}
|
||||
|
|
@ -91,20 +94,21 @@ export class AutoUpdaterService extends EventEmitter {
|
|||
}
|
||||
|
||||
const result = await dialog.showMessageBox(this.mainWindow, {
|
||||
type: 'info',
|
||||
title: 'Update Available',
|
||||
type: "info",
|
||||
title: "Update Available",
|
||||
message: `A new version (${info.version}) is available.`,
|
||||
detail: 'Would you like to download it now? The update will be installed when you restart the app.',
|
||||
buttons: ['Download Now', 'Later'],
|
||||
detail:
|
||||
"Would you like to download it now? The update will be installed when you restart the app.",
|
||||
buttons: ["Download Now", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
logger.updater.info('User chose to download update');
|
||||
logger.updater.info("User chose to download update");
|
||||
autoUpdater.downloadUpdate();
|
||||
} else {
|
||||
logger.updater.info('User chose to skip update');
|
||||
logger.updater.info("User chose to skip update");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,69 +118,75 @@ export class AutoUpdaterService extends EventEmitter {
|
|||
}
|
||||
|
||||
const result = await dialog.showMessageBox(this.mainWindow, {
|
||||
type: 'info',
|
||||
title: 'Update Ready',
|
||||
type: "info",
|
||||
title: "Update Ready",
|
||||
message: `Update ${info.version} has been downloaded.`,
|
||||
detail: 'The update will be installed when you restart the app. Would you like to restart now?',
|
||||
buttons: ['Restart Now', 'Later'],
|
||||
detail:
|
||||
"The update will be installed when you restart the app. Would you like to restart now?",
|
||||
buttons: ["Restart Now", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
logger.updater.info('User chose to restart and install update');
|
||||
logger.updater.info("User chose to restart and install update");
|
||||
autoUpdater.quitAndInstall();
|
||||
} else {
|
||||
logger.updater.info('User chose to install update later');
|
||||
logger.updater.info("User chose to install update later");
|
||||
}
|
||||
}
|
||||
|
||||
async checkForUpdates(userInitiated = false): Promise<void> {
|
||||
// Skip in development
|
||||
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
|
||||
logger.updater.info('Skipping update check in development mode');
|
||||
if (process.env.NODE_ENV === "development" || !app.isPackaged) {
|
||||
logger.updater.info("Skipping update check in development mode");
|
||||
if (userInitiated && this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
dialog.showMessageBox(this.mainWindow, {
|
||||
type: 'info',
|
||||
title: 'Development Mode',
|
||||
message: 'Update checking is disabled in development mode.',
|
||||
buttons: ['OK']
|
||||
type: "info",
|
||||
title: "Development Mode",
|
||||
message: "Update checking is disabled in development mode.",
|
||||
buttons: ["OK"],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.checkingForUpdate) {
|
||||
logger.updater.info('Already checking for updates');
|
||||
logger.updater.info("Already checking for updates");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.updater.info('Starting update check', { userInitiated });
|
||||
logger.updater.info("Starting update check", { userInitiated });
|
||||
await autoUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
logger.updater.error('Failed to check for updates', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
logger.updater.error("Failed to check for updates", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
|
||||
if (userInitiated && this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
dialog.showErrorBox('Update Check Failed', 'Failed to check for updates. Please try again later.');
|
||||
dialog.showErrorBox(
|
||||
"Update Check Failed",
|
||||
"Failed to check for updates. Please try again later.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async checkForUpdatesAndNotify(): Promise<void> {
|
||||
// Skip in development
|
||||
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
|
||||
logger.updater.info('Skipping background update check in development mode');
|
||||
if (process.env.NODE_ENV === "development" || !app.isPackaged) {
|
||||
logger.updater.info(
|
||||
"Skipping background update check in development mode",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await autoUpdater.checkForUpdatesAndNotify();
|
||||
} catch (error) {
|
||||
logger.updater.error('Failed to check for updates and notify', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
logger.updater.error("Failed to check for updates and notify", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -191,20 +201,20 @@ export class AutoUpdaterService extends EventEmitter {
|
|||
|
||||
async downloadUpdate(): Promise<void> {
|
||||
// Skip in development
|
||||
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
|
||||
logger.updater.info('Skipping update download in development mode');
|
||||
throw new Error('Update downloads are disabled in development mode');
|
||||
if (process.env.NODE_ENV === "development" || !app.isPackaged) {
|
||||
logger.updater.info("Skipping update download in development mode");
|
||||
throw new Error("Update downloads are disabled in development mode");
|
||||
}
|
||||
|
||||
if (!this.updateAvailable) {
|
||||
throw new Error('No update available to download');
|
||||
throw new Error("No update available to download");
|
||||
}
|
||||
|
||||
try {
|
||||
await autoUpdater.downloadUpdate();
|
||||
} catch (error) {
|
||||
logger.updater.error('Failed to download update', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
logger.updater.error("Failed to download update", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -212,11 +222,11 @@ export class AutoUpdaterService extends EventEmitter {
|
|||
|
||||
quitAndInstall(): void {
|
||||
// Skip in development
|
||||
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
|
||||
logger.updater.info('Skipping quit and install in development mode');
|
||||
if (process.env.NODE_ENV === "development" || !app.isPackaged) {
|
||||
logger.updater.info("Skipping quit and install in development mode");
|
||||
return;
|
||||
}
|
||||
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import process from 'node:process'; // Added import for process
|
||||
import { app, app as electronApp } from 'electron'; // electronApp for app.getAppPath() consistency
|
||||
import split2 from 'split2';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { spawn, ChildProcessWithoutNullStreams } from "child_process";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import process from "node:process"; // Added import for process
|
||||
import { app, app as electronApp } from "electron"; // electronApp for app.getAppPath() consistency
|
||||
import split2 from "split2";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { createScopedLogger } from './logger';
|
||||
import { EventEmitter } from "events";
|
||||
import { createScopedLogger } from "./logger";
|
||||
import {
|
||||
RpcRequestSchema,
|
||||
RpcRequest,
|
||||
|
|
@ -25,7 +25,7 @@ import {
|
|||
MuteSystemAudioResult,
|
||||
RestoreSystemAudioParams,
|
||||
RestoreSystemAudioResult,
|
||||
} from '@amical/types';
|
||||
} from "@amical/types";
|
||||
|
||||
// Define the interface for RPC methods
|
||||
interface RPCMethods {
|
||||
|
|
@ -63,9 +63,12 @@ interface SwiftIOBridgeEvents {
|
|||
|
||||
export class SwiftIOBridge extends EventEmitter {
|
||||
private proc: ChildProcessWithoutNullStreams | null = null;
|
||||
private pending = new Map<string, { callback: (resp: RpcResponse) => void; startTime: number }>();
|
||||
private pending = new Map<
|
||||
string,
|
||||
{ callback: (resp: RpcResponse) => void; startTime: number }
|
||||
>();
|
||||
private helperPath: string;
|
||||
private logger = createScopedLogger('swift-bridge');
|
||||
private logger = createScopedLogger("swift-bridge");
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -74,18 +77,18 @@ export class SwiftIOBridge extends EventEmitter {
|
|||
}
|
||||
|
||||
private determineHelperPath(): string {
|
||||
const helperName = 'SwiftHelper'; // Swift native helper executable
|
||||
const helperName = "SwiftHelper"; // Swift native helper executable
|
||||
return electronApp.isPackaged
|
||||
? path.join(process.resourcesPath, 'bin', helperName)
|
||||
? path.join(process.resourcesPath, "bin", helperName)
|
||||
: path.join(
|
||||
electronApp.getAppPath(),
|
||||
'..',
|
||||
'..',
|
||||
'packages',
|
||||
'native-helpers',
|
||||
'swift-helper',
|
||||
'bin',
|
||||
helperName
|
||||
"..",
|
||||
"..",
|
||||
"packages",
|
||||
"native-helpers",
|
||||
"swift-helper",
|
||||
"bin",
|
||||
helperName,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -93,27 +96,27 @@ export class SwiftIOBridge extends EventEmitter {
|
|||
try {
|
||||
fs.accessSync(this.helperPath, fs.constants.X_OK);
|
||||
} catch (err) {
|
||||
this.logger.error('SwiftHelper executable not found or not executable', {
|
||||
this.logger.error("SwiftHelper executable not found or not executable", {
|
||||
helperPath: this.helperPath,
|
||||
});
|
||||
this.emit(
|
||||
'error',
|
||||
"error",
|
||||
new Error(
|
||||
`Helper executable not found at ${this.helperPath}. Attempt to build it if in dev mode.`
|
||||
)
|
||||
`Helper executable not found at ${this.helperPath}. Attempt to build it if in dev mode.`,
|
||||
),
|
||||
);
|
||||
// In a real app, you might try to build it here or provide more robust error handling.
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Spawning SwiftHelper', { helperPath: this.helperPath });
|
||||
this.proc = spawn(this.helperPath, [], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
this.logger.info("Spawning SwiftHelper", { helperPath: this.helperPath });
|
||||
this.proc = spawn(this.helperPath, [], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
|
||||
this.proc.stdout.pipe(split2()).on('data', (line: string) => {
|
||||
this.proc.stdout.pipe(split2()).on("data", (line: string) => {
|
||||
if (!line.trim()) return; // Ignore empty lines
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
this.logger.debug('Received message from helper', { message });
|
||||
this.logger.debug("Received message from helper", { message });
|
||||
|
||||
// Try to parse as RpcResponse first
|
||||
const responseValidation = RpcResponseSchema.safeParse(message);
|
||||
|
|
@ -130,52 +133,57 @@ export class SwiftIOBridge extends EventEmitter {
|
|||
const eventValidation = HelperEventSchema.safeParse(message);
|
||||
if (eventValidation.success) {
|
||||
const helperEvent = eventValidation.data;
|
||||
this.emit('helperEvent', helperEvent);
|
||||
this.emit("helperEvent", helperEvent);
|
||||
return; // Handled as a helper event
|
||||
}
|
||||
|
||||
// If it's neither a recognized RPC response nor a helper event
|
||||
this.logger.warn('Received unknown message from helper', { message });
|
||||
this.logger.warn("Received unknown message from helper", { message });
|
||||
} catch (e) {
|
||||
this.logger.error('Error parsing JSON from helper', { error: e, line });
|
||||
this.emit('error', new Error(`Error parsing JSON from helper: ${line}`));
|
||||
this.logger.error("Error parsing JSON from helper", { error: e, line });
|
||||
this.emit(
|
||||
"error",
|
||||
new Error(`Error parsing JSON from helper: ${line}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.proc.stderr.on('data', (data: Buffer) => {
|
||||
this.proc.stderr.on("data", (data: Buffer) => {
|
||||
const errorMsg = data.toString();
|
||||
this.logger.warn('SwiftHelper stderr output', { message: errorMsg });
|
||||
this.logger.warn("SwiftHelper stderr output", { message: errorMsg });
|
||||
// Don't emit as error since stderr is often just debug info
|
||||
// this.emit('error', new Error(`Helper stderr: ${errorMsg}`));
|
||||
});
|
||||
|
||||
this.proc.on('error', (err) => {
|
||||
this.logger.error('Failed to start SwiftHelper process', { error: err });
|
||||
this.emit('error', err);
|
||||
this.proc.on("error", (err) => {
|
||||
this.logger.error("Failed to start SwiftHelper process", { error: err });
|
||||
this.emit("error", err);
|
||||
this.proc = null;
|
||||
});
|
||||
|
||||
this.proc.on('close', (code, signal) => {
|
||||
this.logger.info('SwiftHelper process exited', { code, signal });
|
||||
this.emit('close', code, signal);
|
||||
this.proc.on("close", (code, signal) => {
|
||||
this.logger.info("SwiftHelper process exited", { code, signal });
|
||||
this.emit("close", code, signal);
|
||||
this.proc = null;
|
||||
// Optionally, implement retry logic or notify further
|
||||
});
|
||||
|
||||
process.nextTick(() => {
|
||||
this.emit('ready'); // Emit ready on next tick
|
||||
this.emit("ready"); // Emit ready on next tick
|
||||
});
|
||||
this.logger.info('Helper process started and listeners attached');
|
||||
this.logger.info("Helper process started and listeners attached");
|
||||
}
|
||||
|
||||
public call<M extends keyof RPCMethods>(
|
||||
method: M,
|
||||
params: RPCMethods[M]['params'],
|
||||
timeoutMs = 5000
|
||||
): Promise<RPCMethods[M]['result']> {
|
||||
params: RPCMethods[M]["params"],
|
||||
timeoutMs = 5000,
|
||||
): Promise<RPCMethods[M]["result"]> {
|
||||
if (!this.proc || !this.proc.stdin || !this.proc.stdin.writable) {
|
||||
return Promise.reject(
|
||||
new Error('Swift helper process is not running or stdin is not writable.')
|
||||
new Error(
|
||||
"Swift helper process is not running or stdin is not writable.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -186,61 +194,69 @@ export class SwiftIOBridge extends EventEmitter {
|
|||
// Validate request payload before sending
|
||||
const validationResult = RpcRequestSchema.safeParse(requestPayload);
|
||||
if (!validationResult.success) {
|
||||
this.logger.error('Invalid RPC request payload', {
|
||||
this.logger.error("Invalid RPC request payload", {
|
||||
method,
|
||||
error: validationResult.error.flatten(),
|
||||
});
|
||||
return Promise.reject(
|
||||
new Error(`Invalid RPC request payload: ${validationResult.error.message}`)
|
||||
new Error(
|
||||
`Invalid RPC request payload: ${validationResult.error.message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug('Sending RPC request', {
|
||||
this.logger.debug("Sending RPC request", {
|
||||
method,
|
||||
id,
|
||||
startedAt: new Date(startTime).toISOString(),
|
||||
});
|
||||
this.proc.stdin.write(JSON.stringify(requestPayload) + '\n', (err) => {
|
||||
this.proc.stdin.write(JSON.stringify(requestPayload) + "\n", (err) => {
|
||||
if (err) {
|
||||
this.logger.error('Error writing to helper stdin', { method, id, error: err });
|
||||
this.logger.error("Error writing to helper stdin", {
|
||||
method,
|
||||
id,
|
||||
error: err,
|
||||
});
|
||||
// Note: The promise might have already been set up, consider how to reject it.
|
||||
// For now, this error will be logged. The timeout will eventually reject.
|
||||
} else {
|
||||
this.logger.debug('Successfully sent RPC request', { method, id });
|
||||
this.logger.debug("Successfully sent RPC request", { method, id });
|
||||
}
|
||||
});
|
||||
|
||||
const responsePromise = new Promise<RPCMethods[M]['result']>((resolve, reject) => {
|
||||
this.pending.set(id, {
|
||||
callback: (resp: RpcResponse) => {
|
||||
this.pending.delete(id); // Clean up immediately
|
||||
const completedAt = Date.now();
|
||||
const duration = completedAt - startTime;
|
||||
const responsePromise = new Promise<RPCMethods[M]["result"]>(
|
||||
(resolve, reject) => {
|
||||
this.pending.set(id, {
|
||||
callback: (resp: RpcResponse) => {
|
||||
this.pending.delete(id); // Clean up immediately
|
||||
const completedAt = Date.now();
|
||||
const duration = completedAt - startTime;
|
||||
|
||||
if (resp.error) {
|
||||
const error = new Error(resp.error.message);
|
||||
(error as any).code = resp.error.code;
|
||||
(error as any).data = resp.error.data;
|
||||
reject(error);
|
||||
} else {
|
||||
// Log the raw resp.result with timing information
|
||||
this.logger.debug('Raw RPC response result received', {
|
||||
method,
|
||||
id,
|
||||
result: resp.result,
|
||||
startedAt: new Date(startTime).toISOString(),
|
||||
completedAt: new Date(completedAt).toISOString(),
|
||||
durationMs: duration,
|
||||
});
|
||||
// Here, we might need to validate resp.result against the specific method's result schema
|
||||
// For now, casting as any, but for type safety, validation is better.
|
||||
// Example: const resultValidation = RPCMethods[method].resultSchema.safeParse(resp.result);
|
||||
resolve(resp.result as any);
|
||||
}
|
||||
},
|
||||
startTime,
|
||||
});
|
||||
});
|
||||
if (resp.error) {
|
||||
const error = new Error(resp.error.message);
|
||||
(error as any).code = resp.error.code;
|
||||
(error as any).data = resp.error.data;
|
||||
reject(error);
|
||||
} else {
|
||||
// Log the raw resp.result with timing information
|
||||
this.logger.debug("Raw RPC response result received", {
|
||||
method,
|
||||
id,
|
||||
result: resp.result,
|
||||
startedAt: new Date(startTime).toISOString(),
|
||||
completedAt: new Date(completedAt).toISOString(),
|
||||
durationMs: duration,
|
||||
});
|
||||
// Here, we might need to validate resp.result against the specific method's result schema
|
||||
// For now, casting as any, but for type safety, validation is better.
|
||||
// Example: const resultValidation = RPCMethods[method].resultSchema.safeParse(resp.result);
|
||||
resolve(resp.result as any);
|
||||
}
|
||||
},
|
||||
startTime,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
|
|
@ -251,8 +267,8 @@ export class SwiftIOBridge extends EventEmitter {
|
|||
const duration = timedOutAt - startTime;
|
||||
reject(
|
||||
new Error(
|
||||
`SwiftIOBridge: RPC call "${method}" (id: ${id}) timed out after ${timeoutMs}ms (duration: ${duration}ms, started: ${new Date(startTime).toISOString()})`
|
||||
)
|
||||
`SwiftIOBridge: RPC call "${method}" (id: ${id}) timed out after ${timeoutMs}ms (duration: ${duration}ms, started: ${new Date(startTime).toISOString()})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
|
@ -267,14 +283,17 @@ export class SwiftIOBridge extends EventEmitter {
|
|||
|
||||
public stopHelper(): void {
|
||||
if (this.proc) {
|
||||
this.logger.info('Stopping SwiftHelper process');
|
||||
this.logger.info("Stopping SwiftHelper process");
|
||||
this.proc.kill();
|
||||
this.proc = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Typed event emitter methods
|
||||
on<E extends keyof SwiftIOBridgeEvents>(event: E, listener: SwiftIOBridgeEvents[E]): this {
|
||||
on<E extends keyof SwiftIOBridgeEvents>(
|
||||
event: E,
|
||||
listener: SwiftIOBridgeEvents[E],
|
||||
): this {
|
||||
super.on(event, listener);
|
||||
return this;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { TranscriptionClient } from './transcription-client';
|
||||
import { FormatterService } from '../formatter';
|
||||
import { TranscriptionClient } from "./transcription-client";
|
||||
import { FormatterService } from "../formatter";
|
||||
|
||||
export class AiService {
|
||||
private transcriptionClient: TranscriptionClient;
|
||||
|
|
@ -12,14 +12,16 @@ export class AiService {
|
|||
|
||||
async transcribeAudio(audioData: Buffer): Promise<string> {
|
||||
if (!this.transcriptionClient) {
|
||||
throw new Error('Transcription client is not initialized.');
|
||||
throw new Error("Transcription client is not initialized.");
|
||||
}
|
||||
|
||||
// Step 1: Transcribe audio
|
||||
const transcribedText = await this.transcriptionClient.transcribe(audioData);
|
||||
const transcribedText =
|
||||
await this.transcriptionClient.transcribe(audioData);
|
||||
|
||||
// Step 2: Format the transcribed text if formatter is enabled
|
||||
const formattedText = await this.formatterService.formatText(transcribedText);
|
||||
const formattedText =
|
||||
await this.formatterService.formatText(transcribedText);
|
||||
|
||||
return formattedText;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { TranscriptionClient } from './transcription-client';
|
||||
import * as fs from 'fs';
|
||||
import { logger } from '../../main/logger';
|
||||
import { ModelManagerService } from '../models/model-manager';
|
||||
import { TranscriptionClient } from "./transcription-client";
|
||||
import * as fs from "fs";
|
||||
import { logger } from "../../main/logger";
|
||||
import { ModelManagerService } from "../models/model-manager";
|
||||
|
||||
export class LocalWhisperClient implements TranscriptionClient {
|
||||
private modelManager: ModelManagerService;
|
||||
|
|
@ -20,15 +20,17 @@ export class LocalWhisperClient implements TranscriptionClient {
|
|||
|
||||
const modelPath = await this.getBestAvailableModel();
|
||||
if (!modelPath) {
|
||||
throw new Error('No Whisper models available. Please download a model first.');
|
||||
throw new Error(
|
||||
"No Whisper models available. Please download a model first.",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { Whisper } = await import('smart-whisper');
|
||||
const { Whisper } = await import("smart-whisper");
|
||||
this.whisperInstance = new Whisper(modelPath, { gpu: true });
|
||||
logger.ai.info('Smart-whisper initialized', { modelPath });
|
||||
logger.ai.info("Smart-whisper initialized", { modelPath });
|
||||
} catch (error) {
|
||||
logger.ai.error('Failed to initialize smart-whisper', {
|
||||
logger.ai.error("Failed to initialize smart-whisper", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
modelPath,
|
||||
});
|
||||
|
|
@ -43,25 +45,28 @@ export class LocalWhisperClient implements TranscriptionClient {
|
|||
// Convert audio buffer to the format expected by smart-whisper
|
||||
const audioFloat32Array = await this.convertAudioBuffer(audioData);
|
||||
|
||||
logger.ai.info('Starting smart-whisper transcription', {
|
||||
logger.ai.info("Starting smart-whisper transcription", {
|
||||
audioDataSize: audioData.length,
|
||||
convertedSize: audioFloat32Array.length,
|
||||
});
|
||||
|
||||
// Transcribe using smart-whisper
|
||||
const { result } = await this.whisperInstance.transcribe(audioFloat32Array, {
|
||||
language: 'auto',
|
||||
});
|
||||
const { result } = await this.whisperInstance.transcribe(
|
||||
audioFloat32Array,
|
||||
{
|
||||
language: "auto",
|
||||
},
|
||||
);
|
||||
|
||||
const transcription = await result;
|
||||
|
||||
logger.ai.info('Smart-whisper transcription completed', {
|
||||
logger.ai.info("Smart-whisper transcription completed", {
|
||||
resultLength: transcription.length,
|
||||
});
|
||||
|
||||
return transcription;
|
||||
} catch (error) {
|
||||
logger.ai.error('Smart-whisper transcription failed', {
|
||||
logger.ai.error("Smart-whisper transcription failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw new Error(`Transcription failed: ${error}`);
|
||||
|
|
@ -85,7 +90,7 @@ export class LocalWhisperClient implements TranscriptionClient {
|
|||
|
||||
return float32Array;
|
||||
} catch (error) {
|
||||
logger.ai.warn('Audio conversion failed, trying alternative method', {
|
||||
logger.ai.warn("Audio conversion failed, trying alternative method", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
|
|
@ -114,11 +119,11 @@ export class LocalWhisperClient implements TranscriptionClient {
|
|||
|
||||
// Otherwise, find the best available model (prioritize by quality)
|
||||
const preferredOrder = [
|
||||
'whisper-large-v1',
|
||||
'whisper-medium',
|
||||
'whisper-small',
|
||||
'whisper-base',
|
||||
'whisper-tiny',
|
||||
"whisper-large-v1",
|
||||
"whisper-medium",
|
||||
"whisper-small",
|
||||
"whisper-base",
|
||||
"whisper-tiny",
|
||||
];
|
||||
|
||||
for (const modelId of preferredOrder) {
|
||||
|
|
@ -144,7 +149,7 @@ export class LocalWhisperClient implements TranscriptionClient {
|
|||
}
|
||||
|
||||
this.selectedModelId = modelId;
|
||||
logger.ai.info('Selected model for transcription', { modelId });
|
||||
logger.ai.info("Selected model for transcription", { modelId });
|
||||
}
|
||||
|
||||
// Get the currently selected model
|
||||
|
|
@ -156,7 +161,7 @@ export class LocalWhisperClient implements TranscriptionClient {
|
|||
async isAvailable(): Promise<boolean> {
|
||||
const downloadedModels = await this.modelManager.getDownloadedModels();
|
||||
return Object.keys(downloadedModels).some((modelId) =>
|
||||
fs.existsSync(downloadedModels[modelId].localPath)
|
||||
fs.existsSync(downloadedModels[modelId].localPath),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -164,7 +169,7 @@ export class LocalWhisperClient implements TranscriptionClient {
|
|||
async getAvailableModels(): Promise<string[]> {
|
||||
const downloadedModels = await this.modelManager.getDownloadedModels();
|
||||
return Object.keys(downloadedModels).filter((modelId) =>
|
||||
fs.existsSync(downloadedModels[modelId].localPath)
|
||||
fs.existsSync(downloadedModels[modelId].localPath),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -177,9 +182,9 @@ export class LocalWhisperClient implements TranscriptionClient {
|
|||
if (this.whisperInstance) {
|
||||
try {
|
||||
await this.whisperInstance.free();
|
||||
logger.ai.info('Smart-whisper instance freed');
|
||||
logger.ai.info("Smart-whisper instance freed");
|
||||
} catch (error) {
|
||||
logger.ai.warn('Error freeing smart-whisper instance', {
|
||||
logger.ai.warn("Error freeing smart-whisper instance", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import fs, { statSync } from 'node:fs'; // Import statSync
|
||||
import path from 'node:path';
|
||||
import { app } from 'electron'; // To get a writable path like appData
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs, { statSync } from "node:fs"; // Import statSync
|
||||
import path from "node:path";
|
||||
import { app } from "electron"; // To get a writable path like appData
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
export class AudioCapture extends EventEmitter {
|
||||
private currentRecordingPath: string | null = null;
|
||||
|
|
@ -12,7 +12,7 @@ export class AudioCapture extends EventEmitter {
|
|||
constructor() {
|
||||
super();
|
||||
// Ensure the recordings directory exists
|
||||
const recordingsDir = path.join(app.getPath('userData'), 'recordings');
|
||||
const recordingsDir = path.join(app.getPath("userData"), "recordings");
|
||||
if (!fs.existsSync(recordingsDir)) {
|
||||
fs.mkdirSync(recordingsDir, { recursive: true });
|
||||
}
|
||||
|
|
@ -25,39 +25,45 @@ export class AudioCapture extends EventEmitter {
|
|||
private finalizeRecording(): void {
|
||||
if (!this.writableStream) {
|
||||
console.warn(
|
||||
'AudioCapture: finalizeRecording called but no writableStream active. This might indicate a prior error or premature call.'
|
||||
"AudioCapture: finalizeRecording called but no writableStream active. This might indicate a prior error or premature call.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('AudioCapture: finalizeRecording() called, ending writable stream.');
|
||||
console.log(
|
||||
"AudioCapture: finalizeRecording() called, ending writable stream.",
|
||||
);
|
||||
const streamToClose = this.writableStream;
|
||||
const recordingPathToFinalize = this.currentRecordingPath;
|
||||
|
||||
this.writableStream = null; // Prevent new writes and signal "not recording"
|
||||
|
||||
streamToClose.end(() => {
|
||||
console.log(`AudioCapture: Writable stream .end() callback for: ${recordingPathToFinalize}`);
|
||||
console.log(
|
||||
`AudioCapture: Writable stream .end() callback for: ${recordingPathToFinalize}`,
|
||||
);
|
||||
if (recordingPathToFinalize) {
|
||||
try {
|
||||
const stats = statSync(recordingPathToFinalize);
|
||||
console.log(
|
||||
`AudioCapture: File size of ${recordingPathToFinalize} is ${stats.size} bytes before emitting 'recording-finished'.`
|
||||
`AudioCapture: File size of ${recordingPathToFinalize} is ${stats.size} bytes before emitting 'recording-finished'.`,
|
||||
);
|
||||
if (stats.size === 0) {
|
||||
console.warn(
|
||||
`AudioCapture: File ${recordingPathToFinalize} is empty. Transcription will likely fail.`
|
||||
`AudioCapture: File ${recordingPathToFinalize} is empty. Transcription will likely fail.`,
|
||||
);
|
||||
}
|
||||
this.emit('recording-finished', recordingPathToFinalize);
|
||||
this.emit("recording-finished", recordingPathToFinalize);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`AudioCapture: Error getting file stats for ${recordingPathToFinalize}:`,
|
||||
error
|
||||
error,
|
||||
);
|
||||
this.emit(
|
||||
'recording-error',
|
||||
new Error(`Failed to get stats for ${recordingPathToFinalize}: ${error.message}`)
|
||||
"recording-error",
|
||||
new Error(
|
||||
`Failed to get stats for ${recordingPathToFinalize}: ${error.message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Only nullify currentRecordingPath if it matches the one being finalized.
|
||||
|
|
@ -70,9 +76,9 @@ export class AudioCapture extends EventEmitter {
|
|||
});
|
||||
|
||||
// The 'finish' event on streamToClose is mostly for logging here.
|
||||
streamToClose.on('finish', () => {
|
||||
streamToClose.on("finish", () => {
|
||||
console.log(
|
||||
`AudioCapture: Writable stream 'finish' event for the recording at ${recordingPathToFinalize}.`
|
||||
`AudioCapture: Writable stream 'finish' event for the recording at ${recordingPathToFinalize}.`,
|
||||
);
|
||||
// Clean up path if still relevant, though .end() callback should handle primary cleanup.
|
||||
if (this.currentRecordingPath === recordingPathToFinalize) {
|
||||
|
|
@ -90,27 +96,27 @@ export class AudioCapture extends EventEmitter {
|
|||
// No active stream, this could be the start of a new recording
|
||||
if (chunk.length > 0) {
|
||||
// First non-empty chunk: Start a new recording
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
this.sessionId = `session-${timestamp}`;
|
||||
this.chunkCounter = 0;
|
||||
this.currentRecordingPath = path.join(
|
||||
app.getPath('userData'),
|
||||
'recordings',
|
||||
`recording-${timestamp}.webm`
|
||||
app.getPath("userData"),
|
||||
"recordings",
|
||||
`recording-${timestamp}.webm`,
|
||||
);
|
||||
|
||||
const newStream = fs.createWriteStream(this.currentRecordingPath);
|
||||
const recordingPathForThisStream = this.currentRecordingPath; // Capture path for this specific stream instance
|
||||
console.log(
|
||||
`AudioCapture: New recording started by first chunk. Saving to: ${recordingPathForThisStream}`
|
||||
`AudioCapture: New recording started by first chunk. Saving to: ${recordingPathForThisStream}`,
|
||||
);
|
||||
|
||||
newStream.on('error', (err) => {
|
||||
newStream.on("error", (err) => {
|
||||
console.error(
|
||||
`AudioCapture: Error on writable stream for ${recordingPathForThisStream}:`,
|
||||
err
|
||||
err,
|
||||
);
|
||||
this.emit('recording-error', err);
|
||||
this.emit("recording-error", err);
|
||||
|
||||
// If the currently active stream in the class is the one that errored, nullify it.
|
||||
if (this.writableStream === newStream) {
|
||||
|
|
@ -133,9 +139,9 @@ export class AudioCapture extends EventEmitter {
|
|||
if (writeError) {
|
||||
console.error(
|
||||
`AudioCapture: Error writing initial audio chunk to ${recordingPathForThisStream}:`,
|
||||
writeError
|
||||
writeError,
|
||||
);
|
||||
this.emit('recording-error', writeError);
|
||||
this.emit("recording-error", writeError);
|
||||
// If this write fails, the stream is likely compromised. Clean up.
|
||||
if (this.writableStream === newStream) {
|
||||
// Check if it's still our current stream
|
||||
|
|
@ -153,8 +159,10 @@ export class AudioCapture extends EventEmitter {
|
|||
|
||||
// Emit chunk-ready event for immediate transcription
|
||||
this.chunkCounter++;
|
||||
console.log(`AudioCapture: Emitting chunk-ready for chunk ${this.chunkCounter}`);
|
||||
this.emit('chunk-ready', {
|
||||
console.log(
|
||||
`AudioCapture: Emitting chunk-ready for chunk ${this.chunkCounter}`,
|
||||
);
|
||||
this.emit("chunk-ready", {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: this.chunkCounter,
|
||||
audioData: chunk,
|
||||
|
|
@ -164,7 +172,7 @@ export class AudioCapture extends EventEmitter {
|
|||
// If this very first chunk is also the final chunk
|
||||
if (isFinalChunk) {
|
||||
console.log(
|
||||
'AudioCapture: First chunk is also the final chunk. Finalizing immediately.'
|
||||
"AudioCapture: First chunk is also the final chunk. Finalizing immediately.",
|
||||
);
|
||||
this.finalizeRecording();
|
||||
}
|
||||
|
|
@ -173,11 +181,11 @@ export class AudioCapture extends EventEmitter {
|
|||
// Empty chunk and no stream
|
||||
if (isFinalChunk) {
|
||||
console.log(
|
||||
'AudioCapture: Received an empty final chunk, but no recording was active. No action taken.'
|
||||
"AudioCapture: Received an empty final chunk, but no recording was active. No action taken.",
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
'AudioCapture: Received an empty non-final chunk, but no recording was active. Ignoring.'
|
||||
"AudioCapture: Received an empty non-final chunk, but no recording was active. Ignoring.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -192,9 +200,9 @@ export class AudioCapture extends EventEmitter {
|
|||
if (writeError) {
|
||||
console.error(
|
||||
`AudioCapture: Error writing subsequent audio chunk to ${activePath}:`,
|
||||
writeError
|
||||
writeError,
|
||||
);
|
||||
this.emit('recording-error', writeError);
|
||||
this.emit("recording-error", writeError);
|
||||
// The stream's main 'error' handler should manage cleanup if the stream itself errors.
|
||||
// If only this write fails, but stream doesn't emit 'error', we might need to intervene.
|
||||
// However, a write error often leads to a stream error.
|
||||
|
|
@ -206,8 +214,10 @@ export class AudioCapture extends EventEmitter {
|
|||
} else {
|
||||
// Emit chunk-ready event for immediate transcription
|
||||
this.chunkCounter++;
|
||||
console.log(`AudioCapture: Emitting chunk-ready for chunk ${this.chunkCounter}`);
|
||||
this.emit('chunk-ready', {
|
||||
console.log(
|
||||
`AudioCapture: Emitting chunk-ready for chunk ${this.chunkCounter}`,
|
||||
);
|
||||
this.emit("chunk-ready", {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: this.chunkCounter,
|
||||
audioData: chunk,
|
||||
|
|
@ -215,7 +225,9 @@ export class AudioCapture extends EventEmitter {
|
|||
});
|
||||
|
||||
if (isFinalChunk) {
|
||||
console.log('AudioCapture: Final chunk written successfully. Finalizing recording.');
|
||||
console.log(
|
||||
"AudioCapture: Final chunk written successfully. Finalizing recording.",
|
||||
);
|
||||
this.finalizeRecording();
|
||||
}
|
||||
}
|
||||
|
|
@ -223,14 +235,14 @@ export class AudioCapture extends EventEmitter {
|
|||
} else {
|
||||
// Empty chunk during active recording
|
||||
console.warn(
|
||||
`AudioCapture: Received empty audio chunk while recording to ${activePath}. Not writing to file.`
|
||||
`AudioCapture: Received empty audio chunk while recording to ${activePath}. Not writing to file.`,
|
||||
);
|
||||
if (isFinalChunk) {
|
||||
console.log(
|
||||
'AudioCapture: Empty final chunk received during active recording. Finalizing recording.'
|
||||
"AudioCapture: Empty final chunk received during active recording. Finalizing recording.",
|
||||
);
|
||||
// Still emit the final chunk event even if empty
|
||||
this.emit('chunk-ready', {
|
||||
this.emit("chunk-ready", {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: this.chunkCounter, // Don't increment for empty chunks
|
||||
audioData: chunk,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export abstract class FormatterClient {
|
|||
* Configuration interface for formatter clients
|
||||
*/
|
||||
export interface FormatterConfig {
|
||||
provider: 'openrouter';
|
||||
provider: "openrouter";
|
||||
model: string;
|
||||
apiKey: string;
|
||||
enabled: boolean;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { FormatterClient, FormatterConfig } from './formatter-client';
|
||||
import { OpenRouterFormatterClient } from './openrouter-formatter-client';
|
||||
import { FormatterClient, FormatterConfig } from "./formatter-client";
|
||||
import { OpenRouterFormatterClient } from "./openrouter-formatter-client";
|
||||
|
||||
/**
|
||||
* Main formatter service that manages different formatting providers
|
||||
|
|
@ -20,8 +20,11 @@ export class FormatterService {
|
|||
}
|
||||
|
||||
switch (config.provider) {
|
||||
case 'openrouter':
|
||||
this.client = new OpenRouterFormatterClient(config.apiKey, config.model);
|
||||
case "openrouter":
|
||||
this.client = new OpenRouterFormatterClient(
|
||||
config.apiKey,
|
||||
config.model,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported formatter provider: ${config.provider}`);
|
||||
|
|
@ -40,7 +43,7 @@ export class FormatterService {
|
|||
try {
|
||||
return await this.client.formatText(text);
|
||||
} catch (error) {
|
||||
console.error('Error in formatter service:', error);
|
||||
console.error("Error in formatter service:", error);
|
||||
// Return original text if formatting fails
|
||||
return text;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export { FormatterService } from './formatter-service';
|
||||
export { FormatterClient, FormatterConfig } from './formatter-client';
|
||||
export { OpenRouterFormatterClient } from './openrouter-formatter-client';
|
||||
export { FormatterService } from "./formatter-service";
|
||||
export { FormatterClient, FormatterConfig } from "./formatter-client";
|
||||
export { OpenRouterFormatterClient } from "./openrouter-formatter-client";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { generateText } from 'ai';
|
||||
import { FormatterClient } from './formatter-client';
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { generateText } from "ai";
|
||||
import { FormatterClient } from "./formatter-client";
|
||||
|
||||
/**
|
||||
* OpenRouter-based text formatter client
|
||||
|
|
@ -14,7 +14,7 @@ export class OpenRouterFormatterClient extends FormatterClient {
|
|||
|
||||
// Configure OpenRouter provider
|
||||
this.provider = createOpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
apiKey: apiKey,
|
||||
});
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ export class OpenRouterFormatterClient extends FormatterClient {
|
|||
model: this.provider(this.model),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
role: "system",
|
||||
content: `You are a professional text formatter. Your task is to clean up and improve the formatting of transcribed text while preserving the original meaning and content.
|
||||
|
||||
Please:
|
||||
|
|
@ -41,7 +41,7 @@ Please:
|
|||
Return only the formatted text without any explanations or additional commentary.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
role: "user",
|
||||
content: `Please format this transcribed text:\n\n${text}`,
|
||||
},
|
||||
],
|
||||
|
|
@ -51,7 +51,7 @@ Return only the formatted text without any explanations or additional commentary
|
|||
|
||||
return formattedText;
|
||||
} catch (error) {
|
||||
console.error('Error formatting text with OpenRouter:', error);
|
||||
console.error("Error formatting text with OpenRouter:", error);
|
||||
// Return original text if formatting fails
|
||||
return text;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { app } from 'electron';
|
||||
import { EventEmitter } from "events";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as crypto from "crypto";
|
||||
import { app } from "electron";
|
||||
import {
|
||||
Model,
|
||||
DownloadProgress,
|
||||
ModelManagerState,
|
||||
AVAILABLE_MODELS,
|
||||
} from '../../constants/models';
|
||||
import { DownloadedModel } from '../../db/schema';
|
||||
} from "../../constants/models";
|
||||
import { DownloadedModel } from "../../db/schema";
|
||||
import {
|
||||
getDownloadedModelsRecord,
|
||||
createDownloadedModel,
|
||||
|
|
@ -17,19 +17,25 @@ import {
|
|||
validateDownloadedModels,
|
||||
validateModelFile,
|
||||
getValidDownloadedModels,
|
||||
} from '../../db/downloaded-models';
|
||||
import { logger } from '../../main/logger';
|
||||
} from "../../db/downloaded-models";
|
||||
import { logger } from "../../main/logger";
|
||||
|
||||
interface ModelManagerEvents {
|
||||
'download-progress': (modelId: string, progress: DownloadProgress) => void;
|
||||
'download-complete': (modelId: string, downloadedModel: DownloadedModel) => void;
|
||||
'download-error': (modelId: string, error: Error) => void;
|
||||
'download-cancelled': (modelId: string) => void;
|
||||
'model-deleted': (modelId: string) => void;
|
||||
"download-progress": (modelId: string, progress: DownloadProgress) => void;
|
||||
"download-complete": (
|
||||
modelId: string,
|
||||
downloadedModel: DownloadedModel,
|
||||
) => void;
|
||||
"download-error": (modelId: string, error: Error) => void;
|
||||
"download-cancelled": (modelId: string) => void;
|
||||
"model-deleted": (modelId: string) => void;
|
||||
}
|
||||
|
||||
declare interface ModelManagerService {
|
||||
on<U extends keyof ModelManagerEvents>(event: U, listener: ModelManagerEvents[U]): this;
|
||||
on<U extends keyof ModelManagerEvents>(
|
||||
event: U,
|
||||
listener: ModelManagerEvents[U],
|
||||
): this;
|
||||
emit<U extends keyof ModelManagerEvents>(
|
||||
event: U,
|
||||
...args: Parameters<ModelManagerEvents[U]>
|
||||
|
|
@ -47,7 +53,7 @@ class ModelManagerService extends EventEmitter {
|
|||
};
|
||||
|
||||
// Create models directory in app data
|
||||
this.modelsDirectory = path.join(app.getPath('userData'), 'models');
|
||||
this.modelsDirectory = path.join(app.getPath("userData"), "models");
|
||||
this.ensureModelsDirectory();
|
||||
}
|
||||
|
||||
|
|
@ -57,26 +63,31 @@ class ModelManagerService extends EventEmitter {
|
|||
const validation = await validateDownloadedModels();
|
||||
|
||||
if (validation.cleaned > 0) {
|
||||
logger.main.info('Cleaned up missing model records', {
|
||||
logger.main.info("Cleaned up missing model records", {
|
||||
cleaned: validation.cleaned,
|
||||
valid: validation.valid.length,
|
||||
missing: validation.missing.map((m) => ({ id: m.id, path: m.localPath })),
|
||||
missing: validation.missing.map((m) => ({
|
||||
id: m.id,
|
||||
path: m.localPath,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
logger.main.info('Model manager initialized', {
|
||||
logger.main.info("Model manager initialized", {
|
||||
validModels: validation.valid.length,
|
||||
cleanedRecords: validation.cleaned,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.main.error('Error initializing model manager', { error });
|
||||
logger.main.error("Error initializing model manager", { error });
|
||||
}
|
||||
}
|
||||
|
||||
private ensureModelsDirectory(): void {
|
||||
if (!fs.existsSync(this.modelsDirectory)) {
|
||||
fs.mkdirSync(this.modelsDirectory, { recursive: true });
|
||||
logger.main.info('Created models directory', { path: this.modelsDirectory });
|
||||
logger.main.info("Created models directory", {
|
||||
path: this.modelsDirectory,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -138,17 +149,17 @@ class ModelManagerService extends EventEmitter {
|
|||
const progress: DownloadProgress = {
|
||||
modelId,
|
||||
progress: 0,
|
||||
status: 'downloading',
|
||||
status: "downloading",
|
||||
bytesDownloaded: 0,
|
||||
totalBytes: model.size,
|
||||
abortController,
|
||||
};
|
||||
|
||||
this.state.activeDownloads.set(modelId, progress);
|
||||
this.emit('download-progress', modelId, progress);
|
||||
this.emit("download-progress", modelId, progress);
|
||||
|
||||
try {
|
||||
logger.main.info('Starting model download', {
|
||||
logger.main.info("Starting model download", {
|
||||
modelId,
|
||||
size: model.sizeFormatted,
|
||||
url: model.downloadUrl,
|
||||
|
|
@ -159,10 +170,13 @@ class ModelManagerService extends EventEmitter {
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download: ${response.status} ${response.statusText}`);
|
||||
throw new Error(
|
||||
`Failed to download: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const totalBytes = parseInt(response.headers.get('content-length') || '0') || model.size;
|
||||
const totalBytes =
|
||||
parseInt(response.headers.get("content-length") || "0") || model.size;
|
||||
progress.totalBytes = totalBytes;
|
||||
|
||||
const fileStream = fs.createWriteStream(downloadPath);
|
||||
|
|
@ -171,7 +185,7 @@ class ModelManagerService extends EventEmitter {
|
|||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response reader');
|
||||
throw new Error("Failed to get response reader");
|
||||
}
|
||||
|
||||
while (true) {
|
||||
|
|
@ -182,7 +196,7 @@ class ModelManagerService extends EventEmitter {
|
|||
if (abortController.signal.aborted) {
|
||||
fileStream.close();
|
||||
fs.unlinkSync(downloadPath);
|
||||
throw new Error('Download cancelled');
|
||||
throw new Error("Download cancelled");
|
||||
}
|
||||
|
||||
fileStream.write(value);
|
||||
|
|
@ -197,7 +211,7 @@ class ModelManagerService extends EventEmitter {
|
|||
progressPercent - lastProgressEmit >= 1 ||
|
||||
bytesDownloaded - (lastProgressEmit * totalBytes) / 100 >= 1024 * 1024
|
||||
) {
|
||||
this.emit('download-progress', modelId, { ...progress });
|
||||
this.emit("download-progress", modelId, { ...progress });
|
||||
lastProgressEmit = progressPercent;
|
||||
}
|
||||
}
|
||||
|
|
@ -206,7 +220,7 @@ class ModelManagerService extends EventEmitter {
|
|||
|
||||
// Get actual file size (no validation against expected size)
|
||||
const stats = fs.statSync(downloadPath);
|
||||
logger.main.info('Download completed', {
|
||||
logger.main.info("Download completed", {
|
||||
modelId,
|
||||
expectedSize: totalBytes,
|
||||
actualSize: stats.size,
|
||||
|
|
@ -218,7 +232,9 @@ class ModelManagerService extends EventEmitter {
|
|||
const fileChecksum = await this.calculateFileChecksum(downloadPath);
|
||||
if (fileChecksum !== model.checksum) {
|
||||
fs.unlinkSync(downloadPath);
|
||||
throw new Error(`Checksum mismatch. Expected: ${model.checksum}, Got: ${fileChecksum}`);
|
||||
throw new Error(
|
||||
`Checksum mismatch. Expected: ${model.checksum}, Got: ${fileChecksum}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -236,13 +252,13 @@ class ModelManagerService extends EventEmitter {
|
|||
// Clean up active download
|
||||
this.state.activeDownloads.delete(modelId);
|
||||
|
||||
logger.main.info('Model download completed', {
|
||||
logger.main.info("Model download completed", {
|
||||
modelId,
|
||||
path: downloadPath,
|
||||
size: stats.size,
|
||||
});
|
||||
|
||||
this.emit('download-complete', modelId, downloadedModel);
|
||||
this.emit("download-complete", modelId, downloadedModel);
|
||||
} catch (error) {
|
||||
// Clean up on error
|
||||
this.state.activeDownloads.delete(modelId);
|
||||
|
|
@ -254,11 +270,14 @@ class ModelManagerService extends EventEmitter {
|
|||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
logger.main.info('Model download cancelled', { modelId });
|
||||
this.emit('download-cancelled', modelId);
|
||||
logger.main.info("Model download cancelled", { modelId });
|
||||
this.emit("download-cancelled", modelId);
|
||||
} else {
|
||||
logger.main.error('Model download failed', { modelId, error: err.message });
|
||||
this.emit('download-error', modelId, err);
|
||||
logger.main.error("Model download failed", {
|
||||
modelId,
|
||||
error: err.message,
|
||||
});
|
||||
this.emit("download-error", modelId, err);
|
||||
}
|
||||
|
||||
throw err;
|
||||
|
|
@ -272,14 +291,14 @@ class ModelManagerService extends EventEmitter {
|
|||
throw new Error(`No active download found for model: ${modelId}`);
|
||||
}
|
||||
|
||||
download.status = 'cancelling';
|
||||
download.status = "cancelling";
|
||||
download.abortController?.abort();
|
||||
|
||||
// Immediately remove from active downloads to prevent restart issues
|
||||
this.state.activeDownloads.delete(modelId);
|
||||
|
||||
logger.main.info('Cancelled model download', { modelId });
|
||||
this.emit('download-cancelled', modelId);
|
||||
logger.main.info("Cancelled model download", { modelId });
|
||||
this.emit("download-cancelled", modelId);
|
||||
}
|
||||
|
||||
// Delete a downloaded model
|
||||
|
|
@ -294,7 +313,7 @@ class ModelManagerService extends EventEmitter {
|
|||
// Delete file
|
||||
if (fs.existsSync(downloadedModel.localPath)) {
|
||||
fs.unlinkSync(downloadedModel.localPath);
|
||||
logger.main.info('Deleted model file', {
|
||||
logger.main.info("Deleted model file", {
|
||||
modelId,
|
||||
path: downloadedModel.localPath,
|
||||
});
|
||||
|
|
@ -303,18 +322,18 @@ class ModelManagerService extends EventEmitter {
|
|||
// Remove from database
|
||||
await deleteDownloadedModel(modelId);
|
||||
|
||||
this.emit('model-deleted', modelId);
|
||||
this.emit("model-deleted", modelId);
|
||||
}
|
||||
|
||||
// Calculate file checksum (SHA-1)
|
||||
private async calculateFileChecksum(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha1');
|
||||
const hash = crypto.createHash("sha1");
|
||||
const stream = fs.createReadStream(filePath);
|
||||
|
||||
stream.on('data', (data) => hash.update(data));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', reject);
|
||||
stream.on("data", (data) => hash.update(data));
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -329,7 +348,7 @@ class ModelManagerService extends EventEmitter {
|
|||
const validation = await validateDownloadedModels();
|
||||
|
||||
if (validation.cleaned > 0) {
|
||||
logger.main.info('Periodic cleanup completed', {
|
||||
logger.main.info("Periodic cleanup completed", {
|
||||
cleaned: validation.cleaned,
|
||||
valid: validation.valid.length,
|
||||
});
|
||||
|
|
@ -340,14 +359,14 @@ class ModelManagerService extends EventEmitter {
|
|||
valid: validation.valid.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.main.error('Error during model validation cleanup', { error });
|
||||
logger.main.error("Error during model validation cleanup", { error });
|
||||
return { cleaned: 0, valid: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup - cancel all active downloads
|
||||
cleanup(): void {
|
||||
logger.main.info('Cleaning up model downloads', {
|
||||
logger.main.info("Cleaning up model downloads", {
|
||||
activeDownloads: this.state.activeDownloads.size,
|
||||
});
|
||||
|
||||
|
|
@ -355,7 +374,7 @@ class ModelManagerService extends EventEmitter {
|
|||
try {
|
||||
this.cancelDownload(modelId);
|
||||
} catch (error) {
|
||||
logger.main.warn('Error cancelling download during cleanup', {
|
||||
logger.main.warn("Error cancelling download during cleanup", {
|
||||
modelId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { SettingsService } from './settings-service';
|
||||
export { SettingsService } from "./settings-service";
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { FormatterConfig } from '../formatter';
|
||||
import { FormatterConfig } from "../formatter";
|
||||
import {
|
||||
getSettingsSection,
|
||||
updateSettingsSection,
|
||||
getAppSettings,
|
||||
updateAppSettings,
|
||||
} from '../../db/app-settings';
|
||||
import type { AppSettingsData } from '../../db/schema';
|
||||
} from "../../db/app-settings";
|
||||
import type { AppSettingsData } from "../../db/schema";
|
||||
|
||||
/**
|
||||
* Database-backed settings service with typed configuration
|
||||
|
|
@ -26,7 +26,7 @@ export class SettingsService {
|
|||
* Get formatter configuration
|
||||
*/
|
||||
async getFormatterConfig(): Promise<FormatterConfig | null> {
|
||||
const formatterConfig = await getSettingsSection('formatterConfig');
|
||||
const formatterConfig = await getSettingsSection("formatterConfig");
|
||||
return formatterConfig || null;
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ export class SettingsService {
|
|||
* Set formatter configuration
|
||||
*/
|
||||
async setFormatterConfig(config: FormatterConfig): Promise<void> {
|
||||
await updateSettingsSection('formatterConfig', config);
|
||||
await updateSettingsSection("formatterConfig", config);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -47,51 +47,55 @@ export class SettingsService {
|
|||
/**
|
||||
* Update multiple settings at once
|
||||
*/
|
||||
async updateSettings(settings: Partial<AppSettingsData>): Promise<AppSettingsData> {
|
||||
async updateSettings(
|
||||
settings: Partial<AppSettingsData>,
|
||||
): Promise<AppSettingsData> {
|
||||
return await updateAppSettings(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get UI settings
|
||||
*/
|
||||
async getUISettings(): Promise<AppSettingsData['ui']> {
|
||||
return await getSettingsSection('ui');
|
||||
async getUISettings(): Promise<AppSettingsData["ui"]> {
|
||||
return await getSettingsSection("ui");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI settings
|
||||
*/
|
||||
async setUISettings(uiSettings: AppSettingsData['ui']): Promise<void> {
|
||||
await updateSettingsSection('ui', uiSettings);
|
||||
async setUISettings(uiSettings: AppSettingsData["ui"]): Promise<void> {
|
||||
await updateSettingsSection("ui", uiSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transcription settings
|
||||
*/
|
||||
async getTranscriptionSettings(): Promise<AppSettingsData['transcription']> {
|
||||
return await getSettingsSection('transcription');
|
||||
async getTranscriptionSettings(): Promise<AppSettingsData["transcription"]> {
|
||||
return await getSettingsSection("transcription");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update transcription settings
|
||||
*/
|
||||
async setTranscriptionSettings(
|
||||
transcriptionSettings: AppSettingsData['transcription']
|
||||
transcriptionSettings: AppSettingsData["transcription"],
|
||||
): Promise<void> {
|
||||
await updateSettingsSection('transcription', transcriptionSettings);
|
||||
await updateSettingsSection("transcription", transcriptionSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recording settings
|
||||
*/
|
||||
async getRecordingSettings(): Promise<AppSettingsData['recording']> {
|
||||
return await getSettingsSection('recording');
|
||||
async getRecordingSettings(): Promise<AppSettingsData["recording"]> {
|
||||
return await getSettingsSection("recording");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update recording settings
|
||||
*/
|
||||
async setRecordingSettings(recordingSettings: AppSettingsData['recording']): Promise<void> {
|
||||
await updateSettingsSection('recording', recordingSettings);
|
||||
async setRecordingSettings(
|
||||
recordingSettings: AppSettingsData["recording"],
|
||||
): Promise<void> {
|
||||
await updateSettingsSection("recording", recordingSettings);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { ContextualTranscriptionClient } from './transcription-session';
|
||||
import * as fs from 'fs';
|
||||
import { logger } from '../../main/logger';
|
||||
import { ModelManagerService } from '../models/model-manager';
|
||||
import { TranscribeFormat, TranscribeParams, Whisper } from 'smart-whisper';
|
||||
import { ContextualTranscriptionClient } from "./transcription-session";
|
||||
import * as fs from "fs";
|
||||
import { logger } from "../../main/logger";
|
||||
import { ModelManagerService } from "../models/model-manager";
|
||||
import { TranscribeFormat, TranscribeParams, Whisper } from "smart-whisper";
|
||||
|
||||
export class ContextualLocalWhisperClient implements ContextualTranscriptionClient {
|
||||
export class ContextualLocalWhisperClient
|
||||
implements ContextualTranscriptionClient
|
||||
{
|
||||
private modelManager: ModelManagerService;
|
||||
private selectedModelId: string | null = null;
|
||||
private whisperInstance: Whisper | null = null; // Will be imported from smart-whisper
|
||||
|
|
@ -24,28 +26,42 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
|
||||
const modelPath = await this.getBestAvailableModel();
|
||||
if (!modelPath) {
|
||||
throw new Error('No Whisper models available. Please download a model first.');
|
||||
throw new Error(
|
||||
"No Whisper models available. Please download a model first.",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
//! esure gpu is used if available
|
||||
this.whisperInstance = new Whisper(modelPath, { gpu: true });
|
||||
logger.ai.info('Smart-whisper instance created for contextual transcription', { modelPath });
|
||||
logger.ai.info(
|
||||
"Smart-whisper instance created for contextual transcription",
|
||||
{ modelPath },
|
||||
);
|
||||
// Actually load the model into memory
|
||||
await this.whisperInstance.load();
|
||||
logger.ai.info('Smart-whisper model loaded into memory for contextual transcription', {
|
||||
modelPath,
|
||||
});
|
||||
logger.ai.info(
|
||||
"Smart-whisper model loaded into memory for contextual transcription",
|
||||
{
|
||||
modelPath,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
logger.ai.error('Failed to initialize and load smart-whisper for contextual transcription', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
modelPath,
|
||||
});
|
||||
logger.ai.error(
|
||||
"Failed to initialize and load smart-whisper for contextual transcription",
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
modelPath,
|
||||
},
|
||||
);
|
||||
throw new Error(`Failed to initialize and load smart-whisper: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async transcribeWithContext(audioData: Buffer, previousContext: string): Promise<string> {
|
||||
async transcribeWithContext(
|
||||
audioData: Buffer,
|
||||
previousContext: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
await this.initializeWhisper();
|
||||
this.updateLastUsedTimestamp(); // Update timestamp when model is used
|
||||
|
|
@ -54,19 +70,19 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
const audioFloat32Array = await this.convertAudioBuffer(audioData);
|
||||
|
||||
// Prepare initial prompt with context for better continuity
|
||||
let prompt = '';
|
||||
let prompt = "";
|
||||
if (previousContext && previousContext.trim().length > 0) {
|
||||
// Use last ~50 words as context/prompt
|
||||
const contextWords = previousContext.trim().split(/\s+/);
|
||||
const maxWords = 50;
|
||||
prompt =
|
||||
contextWords.length > maxWords
|
||||
? contextWords.slice(-maxWords).join(' ')
|
||||
? contextWords.slice(-maxWords).join(" ")
|
||||
: previousContext.trim();
|
||||
}
|
||||
|
||||
const modelInfo = await this.getCurrentModelInfo();
|
||||
logger.ai.info('Starting smart-whisper contextual transcription', {
|
||||
logger.ai.info("Starting smart-whisper contextual transcription", {
|
||||
audioDataSize: audioData.length,
|
||||
convertedSize: audioFloat32Array.length,
|
||||
hasContext: prompt.length > 0,
|
||||
|
|
@ -76,9 +92,10 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
});
|
||||
|
||||
// Transcribe using smart-whisper with initial prompt for context
|
||||
const transcriptionOptions: Partial<TranscribeParams<TranscribeFormat>> = {
|
||||
language: 'auto',
|
||||
};
|
||||
const transcriptionOptions: Partial<TranscribeParams<TranscribeFormat>> =
|
||||
{
|
||||
language: "auto",
|
||||
};
|
||||
|
||||
// Add initial prompt if we have context
|
||||
if (prompt) {
|
||||
|
|
@ -87,14 +104,17 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
|
||||
const { result } = await this.whisperInstance!.transcribe(
|
||||
audioFloat32Array,
|
||||
transcriptionOptions
|
||||
transcriptionOptions,
|
||||
);
|
||||
const transcription = await result;
|
||||
|
||||
// Extract text from the result object
|
||||
const transcriptionText = transcription.reduce((acc, curr) => acc + curr.text, '');
|
||||
const transcriptionText = transcription.reduce(
|
||||
(acc, curr) => acc + curr.text,
|
||||
"",
|
||||
);
|
||||
|
||||
logger.ai.info('Smart-whisper contextual transcription completed', {
|
||||
logger.ai.info("Smart-whisper contextual transcription completed", {
|
||||
resultLength: transcriptionText.length,
|
||||
hadContext: prompt.length > 0,
|
||||
resultType: typeof result,
|
||||
|
|
@ -104,7 +124,7 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
|
||||
return transcriptionText;
|
||||
} catch (error) {
|
||||
logger.ai.error('Smart-whisper contextual transcription failed', {
|
||||
logger.ai.error("Smart-whisper contextual transcription failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw new Error(`Contextual transcription failed: ${error}`);
|
||||
|
|
@ -115,7 +135,7 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
// Smart-whisper expects Float32Array with 16kHz mono audio
|
||||
// Now we're receiving raw Float32Array data from Web Audio API
|
||||
|
||||
logger.ai.info('Converting audio buffer', {
|
||||
logger.ai.info("Converting audio buffer", {
|
||||
bufferLength: audioData.length,
|
||||
expectedFloat32Length: audioData.length / 4,
|
||||
});
|
||||
|
|
@ -124,28 +144,31 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
// The audioData should now be raw Float32Array from Web Audio API (16kHz, mono)
|
||||
// Check if buffer length is divisible by 4 (Float32 = 4 bytes)
|
||||
if (audioData.length % 4 !== 0) {
|
||||
logger.ai.warn('Audio buffer length not divisible by 4, may not be Float32Array', {
|
||||
length: audioData.length,
|
||||
remainder: audioData.length % 4,
|
||||
});
|
||||
logger.ai.warn(
|
||||
"Audio buffer length not divisible by 4, may not be Float32Array",
|
||||
{
|
||||
length: audioData.length,
|
||||
remainder: audioData.length % 4,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Convert buffer back to Float32Array
|
||||
const float32Array = new Float32Array(
|
||||
audioData.buffer,
|
||||
audioData.byteOffset,
|
||||
audioData.length / 4
|
||||
audioData.length / 4,
|
||||
);
|
||||
|
||||
logger.ai.info('Successfully converted audio buffer', {
|
||||
logger.ai.info("Successfully converted audio buffer", {
|
||||
sampleCount: float32Array.length,
|
||||
sampleRate: '16kHz (assumed)',
|
||||
format: 'Float32Array',
|
||||
sampleRate: "16kHz (assumed)",
|
||||
format: "Float32Array",
|
||||
});
|
||||
|
||||
return float32Array;
|
||||
} catch (error) {
|
||||
logger.ai.error('Audio conversion failed', {
|
||||
logger.ai.error("Audio conversion failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
|
|
@ -158,13 +181,17 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
samples[i] = sample / 32768.0;
|
||||
}
|
||||
|
||||
logger.ai.info('Fallback: converted as 16-bit PCM', { sampleCount: samples.length });
|
||||
logger.ai.info("Fallback: converted as 16-bit PCM", {
|
||||
sampleCount: samples.length,
|
||||
});
|
||||
return samples;
|
||||
} catch (fallbackError) {
|
||||
logger.ai.error('All audio conversion methods failed', {
|
||||
logger.ai.error("All audio conversion methods failed", {
|
||||
originalError: error instanceof Error ? error.message : String(error),
|
||||
fallbackError:
|
||||
fallbackError instanceof Error ? fallbackError.message : String(fallbackError),
|
||||
fallbackError instanceof Error
|
||||
? fallbackError.message
|
||||
: String(fallbackError),
|
||||
});
|
||||
|
||||
// Return empty array as last resort
|
||||
|
|
@ -186,11 +213,11 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
|
||||
// Otherwise, find the best available model (prioritize by quality)
|
||||
const preferredOrder = [
|
||||
'whisper-large-v1',
|
||||
'whisper-medium',
|
||||
'whisper-small',
|
||||
'whisper-base',
|
||||
'whisper-tiny',
|
||||
"whisper-large-v1",
|
||||
"whisper-medium",
|
||||
"whisper-small",
|
||||
"whisper-base",
|
||||
"whisper-tiny",
|
||||
];
|
||||
|
||||
for (const modelId of preferredOrder) {
|
||||
|
|
@ -216,7 +243,7 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
}
|
||||
|
||||
this.selectedModelId = modelId;
|
||||
logger.ai.info('Selected model for contextual transcription', { modelId });
|
||||
logger.ai.info("Selected model for contextual transcription", { modelId });
|
||||
}
|
||||
|
||||
// Get the currently selected model
|
||||
|
|
@ -228,7 +255,7 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
async isAvailable(): Promise<boolean> {
|
||||
const downloadedModels = await this.modelManager.getDownloadedModels();
|
||||
return Object.keys(downloadedModels).some((modelId) =>
|
||||
fs.existsSync(downloadedModels[modelId].localPath)
|
||||
fs.existsSync(downloadedModels[modelId].localPath),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -236,12 +263,15 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
async getAvailableModels(): Promise<string[]> {
|
||||
const downloadedModels = await this.modelManager.getDownloadedModels();
|
||||
return Object.keys(downloadedModels).filter((modelId) =>
|
||||
fs.existsSync(downloadedModels[modelId].localPath)
|
||||
fs.existsSync(downloadedModels[modelId].localPath),
|
||||
);
|
||||
}
|
||||
|
||||
// Get current model information for logging
|
||||
async getCurrentModelInfo(): Promise<{ modelId: string | null; modelPath: string | null }> {
|
||||
async getCurrentModelInfo(): Promise<{
|
||||
modelId: string | null;
|
||||
modelPath: string | null;
|
||||
}> {
|
||||
const downloadedModels = await this.modelManager.getDownloadedModels();
|
||||
|
||||
// If a specific model is selected and available, use it
|
||||
|
|
@ -257,11 +287,11 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
|
||||
// Otherwise, find the best available model (same logic as getBestAvailableModel)
|
||||
const preferredOrder = [
|
||||
'whisper-large-v1',
|
||||
'whisper-medium',
|
||||
'whisper-small',
|
||||
'whisper-base',
|
||||
'whisper-tiny',
|
||||
"whisper-large-v1",
|
||||
"whisper-medium",
|
||||
"whisper-small",
|
||||
"whisper-base",
|
||||
"whisper-tiny",
|
||||
];
|
||||
|
||||
for (const modelId of preferredOrder) {
|
||||
|
|
@ -281,7 +311,7 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
async loadModel(): Promise<void> {
|
||||
await this.initializeWhisper();
|
||||
this.updateLastUsedTimestamp();
|
||||
logger.ai.info('Model preloaded successfully', {
|
||||
logger.ai.info("Model preloaded successfully", {
|
||||
modelLoaded: this.isModelLoaded(),
|
||||
cleanupDelayMs: this.MODEL_CLEANUP_DELAY_MS,
|
||||
});
|
||||
|
|
@ -291,7 +321,7 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
async freeModel(): Promise<void> {
|
||||
this.clearCleanupTimer();
|
||||
await this.freeWhisperInstance();
|
||||
logger.ai.info('Model freed manually');
|
||||
logger.ai.info("Model freed manually");
|
||||
}
|
||||
|
||||
// Check if model is currently loaded
|
||||
|
|
@ -309,9 +339,9 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
if (this.whisperInstance) {
|
||||
try {
|
||||
await this.whisperInstance.free();
|
||||
logger.ai.info('Smart-whisper contextual instance freed');
|
||||
logger.ai.info("Smart-whisper contextual instance freed");
|
||||
} catch (error) {
|
||||
logger.ai.warn('Error freeing smart-whisper contextual instance', {
|
||||
logger.ai.warn("Error freeing smart-whisper contextual instance", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -332,7 +362,7 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
const timeSinceLastUse = Date.now() - this.lastUsedTimestamp;
|
||||
|
||||
if (timeSinceLastUse >= this.MODEL_CLEANUP_DELAY_MS) {
|
||||
logger.ai.info('Auto-freeing model after inactivity', {
|
||||
logger.ai.info("Auto-freeing model after inactivity", {
|
||||
inactiveTimeMs: timeSinceLastUse,
|
||||
thresholdMs: this.MODEL_CLEANUP_DELAY_MS,
|
||||
});
|
||||
|
|
@ -340,7 +370,10 @@ export class ContextualLocalWhisperClient implements ContextualTranscriptionClie
|
|||
} else {
|
||||
// Reschedule if model was used recently
|
||||
const remainingTime = this.MODEL_CLEANUP_DELAY_MS - timeSinceLastUse;
|
||||
this.cleanupTimer = setTimeout(() => this.scheduleCleanup(), remainingTime);
|
||||
this.cleanupTimer = setTimeout(
|
||||
() => this.scheduleCleanup(),
|
||||
remainingTime,
|
||||
);
|
||||
}
|
||||
}, this.MODEL_CLEANUP_DELAY_MS);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,35 @@
|
|||
import { ContextualTranscriptionClient } from './transcription-session';
|
||||
import { ContextualLocalWhisperClient } from './contextual-local-whisper-client';
|
||||
import { ModelManagerService } from '../models/model-manager';
|
||||
import { createScopedLogger } from '../../main/logger';
|
||||
import { ContextualTranscriptionClient } from "./transcription-session";
|
||||
import { ContextualLocalWhisperClient } from "./contextual-local-whisper-client";
|
||||
import { ModelManagerService } from "../models/model-manager";
|
||||
import { createScopedLogger } from "../../main/logger";
|
||||
|
||||
export class ContextualTranscriptionManager {
|
||||
private logger = createScopedLogger('contextual-transcription-manager');
|
||||
private logger = createScopedLogger("contextual-transcription-manager");
|
||||
private defaultClient: ContextualLocalWhisperClient | null = null;
|
||||
|
||||
constructor(private modelManagerService: ModelManagerService | null = null) {}
|
||||
|
||||
createTranscriptionClient(
|
||||
provider: 'local',
|
||||
options: { modelId?: string } = {}
|
||||
provider: "local",
|
||||
options: { modelId?: string } = {},
|
||||
): ContextualTranscriptionClient {
|
||||
switch (provider) {
|
||||
case 'local':
|
||||
case "local":
|
||||
if (!this.modelManagerService) {
|
||||
throw new Error('ModelManagerService is required for local transcription client');
|
||||
throw new Error(
|
||||
"ModelManagerService is required for local transcription client",
|
||||
);
|
||||
}
|
||||
this.logger.info('Creating local Whisper contextual transcription client', {
|
||||
selectedModelId: options.modelId,
|
||||
});
|
||||
return new ContextualLocalWhisperClient(this.modelManagerService, options.modelId);
|
||||
this.logger.info(
|
||||
"Creating local Whisper contextual transcription client",
|
||||
{
|
||||
selectedModelId: options.modelId,
|
||||
},
|
||||
);
|
||||
return new ContextualLocalWhisperClient(
|
||||
this.modelManagerService,
|
||||
options.modelId,
|
||||
);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown transcription provider: ${provider}`);
|
||||
|
|
@ -29,14 +37,16 @@ export class ContextualTranscriptionManager {
|
|||
}
|
||||
|
||||
// Get the default provider based on configuration
|
||||
getDefaultProvider(): 'local' {
|
||||
return 'local';
|
||||
getDefaultProvider(): "local" {
|
||||
return "local";
|
||||
}
|
||||
|
||||
// Create default client with current configuration
|
||||
createDefaultClient(): ContextualTranscriptionClient {
|
||||
if (!this.defaultClient) {
|
||||
this.defaultClient = this.createTranscriptionClient('local') as ContextualLocalWhisperClient;
|
||||
this.defaultClient = this.createTranscriptionClient(
|
||||
"local",
|
||||
) as ContextualLocalWhisperClient;
|
||||
}
|
||||
return this.defaultClient;
|
||||
}
|
||||
|
|
@ -45,14 +55,14 @@ export class ContextualTranscriptionManager {
|
|||
async preloadModel(): Promise<void> {
|
||||
const client = this.createDefaultClient() as ContextualLocalWhisperClient;
|
||||
await client.loadModel();
|
||||
this.logger.info('Model preloaded for contextual transcription');
|
||||
this.logger.info("Model preloaded for contextual transcription");
|
||||
}
|
||||
|
||||
// Free the model to save memory
|
||||
async freeModel(): Promise<void> {
|
||||
if (this.defaultClient) {
|
||||
await this.defaultClient.freeModel();
|
||||
this.logger.info('Model freed for contextual transcription');
|
||||
this.logger.info("Model freed for contextual transcription");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { EventEmitter } from 'node:events';
|
||||
import { createScopedLogger } from '../../main/logger';
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createScopedLogger } from "../../main/logger";
|
||||
|
||||
export interface ChunkData {
|
||||
sessionId: string;
|
||||
|
|
@ -21,30 +21,39 @@ export interface ChunkResult {
|
|||
}
|
||||
|
||||
export interface ContextualTranscriptionClient {
|
||||
transcribeWithContext(audioData: Buffer, previousContext: string): Promise<string>;
|
||||
getCurrentModelInfo?: () => Promise<{ modelId: string | null; modelPath: string | null }>;
|
||||
transcribeWithContext(
|
||||
audioData: Buffer,
|
||||
previousContext: string,
|
||||
): Promise<string>;
|
||||
getCurrentModelInfo?: () => Promise<{
|
||||
modelId: string | null;
|
||||
modelPath: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class TranscriptionSession extends EventEmitter {
|
||||
private logger = createScopedLogger('transcription-session');
|
||||
private logger = createScopedLogger("transcription-session");
|
||||
private sessionId: string;
|
||||
private transcriptionClient: ContextualTranscriptionClient;
|
||||
|
||||
private chunkQueue: ChunkData[] = [];
|
||||
private results: ChunkResult[] = [];
|
||||
private accumulatedText: string = '';
|
||||
private accumulatedText: string = "";
|
||||
private isProcessing: boolean = false;
|
||||
private expectedChunkId: number = 1;
|
||||
private isComplete: boolean = false;
|
||||
private sessionStartTime: number;
|
||||
|
||||
constructor(sessionId: string, transcriptionClient: ContextualTranscriptionClient) {
|
||||
constructor(
|
||||
sessionId: string,
|
||||
transcriptionClient: ContextualTranscriptionClient,
|
||||
) {
|
||||
super();
|
||||
this.sessionId = sessionId;
|
||||
this.transcriptionClient = transcriptionClient;
|
||||
this.sessionStartTime = Date.now();
|
||||
|
||||
this.logger.info('TranscriptionSession created', {
|
||||
this.logger.info("TranscriptionSession created", {
|
||||
sessionId,
|
||||
sessionStartTime: this.sessionStartTime,
|
||||
sessionStartTimeISO: new Date(this.sessionStartTime).toISOString(),
|
||||
|
|
@ -53,7 +62,7 @@ export class TranscriptionSession extends EventEmitter {
|
|||
|
||||
public addChunk(chunkData: ChunkData): void {
|
||||
if (chunkData.sessionId !== this.sessionId) {
|
||||
this.logger.warn('Received chunk for different session', {
|
||||
this.logger.warn("Received chunk for different session", {
|
||||
expected: this.sessionId,
|
||||
received: chunkData.sessionId,
|
||||
});
|
||||
|
|
@ -61,14 +70,14 @@ export class TranscriptionSession extends EventEmitter {
|
|||
}
|
||||
|
||||
if (this.isComplete) {
|
||||
this.logger.warn('Session already complete, ignoring chunk', {
|
||||
this.logger.warn("Session already complete, ignoring chunk", {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: chunkData.chunkId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Adding chunk to queue', {
|
||||
this.logger.info("Adding chunk to queue", {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: chunkData.chunkId,
|
||||
isFinalChunk: chunkData.isFinalChunk,
|
||||
|
|
@ -86,11 +95,11 @@ export class TranscriptionSession extends EventEmitter {
|
|||
|
||||
// Find the next expected chunk in sequence
|
||||
const nextChunkIndex = this.chunkQueue.findIndex(
|
||||
(chunk) => chunk.chunkId === this.expectedChunkId
|
||||
(chunk) => chunk.chunkId === this.expectedChunkId,
|
||||
);
|
||||
|
||||
if (nextChunkIndex === -1) {
|
||||
this.logger.debug('Next expected chunk not yet available', {
|
||||
this.logger.debug("Next expected chunk not yet available", {
|
||||
expectedChunkId: this.expectedChunkId,
|
||||
availableChunks: this.chunkQueue.map((c) => c.chunkId),
|
||||
});
|
||||
|
|
@ -103,12 +112,12 @@ export class TranscriptionSession extends EventEmitter {
|
|||
try {
|
||||
await this.transcribeChunk(chunk);
|
||||
} catch (error) {
|
||||
this.logger.error('Error processing chunk', {
|
||||
this.logger.error("Error processing chunk", {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: chunk.chunkId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
this.emit('chunk-error', { chunkId: chunk.chunkId, error });
|
||||
this.emit("chunk-error", { chunkId: chunk.chunkId, error });
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
this.expectedChunkId++;
|
||||
|
|
@ -129,7 +138,7 @@ export class TranscriptionSession extends EventEmitter {
|
|||
? await this.transcriptionClient.getCurrentModelInfo()
|
||||
: { modelId: null, modelPath: null };
|
||||
|
||||
this.logger.info('Starting transcription for chunk', {
|
||||
this.logger.info("Starting transcription for chunk", {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: chunk.chunkId,
|
||||
audioDataSize: chunk.audioData.length,
|
||||
|
|
@ -145,7 +154,7 @@ export class TranscriptionSession extends EventEmitter {
|
|||
const endTime = Date.now();
|
||||
const processingTimeMs = endTime - startTime;
|
||||
|
||||
this.logger.info('Skipping transcription for empty chunk', {
|
||||
this.logger.info("Skipping transcription for empty chunk", {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: chunk.chunkId,
|
||||
startTime,
|
||||
|
|
@ -159,7 +168,7 @@ export class TranscriptionSession extends EventEmitter {
|
|||
|
||||
const result: ChunkResult = {
|
||||
chunkId: chunk.chunkId,
|
||||
text: '',
|
||||
text: "",
|
||||
processingTimeMs,
|
||||
startTime,
|
||||
endTime,
|
||||
|
|
@ -167,16 +176,17 @@ export class TranscriptionSession extends EventEmitter {
|
|||
};
|
||||
|
||||
this.results.push(result);
|
||||
this.emit('chunk-completed', result);
|
||||
this.emit("chunk-completed", result);
|
||||
return;
|
||||
}
|
||||
|
||||
const transcriptionText = await this.transcriptionClient.transcribeWithContext(
|
||||
chunk.audioData,
|
||||
this.accumulatedText
|
||||
);
|
||||
const transcriptionText =
|
||||
await this.transcriptionClient.transcribeWithContext(
|
||||
chunk.audioData,
|
||||
this.accumulatedText,
|
||||
);
|
||||
|
||||
console.error('transcriptionText result ', transcriptionText);
|
||||
console.error("transcriptionText result ", transcriptionText);
|
||||
|
||||
const endTime = Date.now();
|
||||
const processingTimeMs = endTime - startTime;
|
||||
|
|
@ -191,11 +201,12 @@ export class TranscriptionSession extends EventEmitter {
|
|||
};
|
||||
|
||||
// Accumulate the transcription text for context
|
||||
this.accumulatedText += (this.accumulatedText ? ' ' : '') + transcriptionText;
|
||||
this.accumulatedText +=
|
||||
(this.accumulatedText ? " " : "") + transcriptionText;
|
||||
|
||||
this.results.push(result);
|
||||
|
||||
this.logger.error('Chunk transcription completed', {
|
||||
this.logger.error("Chunk transcription completed", {
|
||||
sessionId: this.sessionId,
|
||||
chunkId: chunk.chunkId,
|
||||
textLength: transcriptionText.length,
|
||||
|
|
@ -209,7 +220,7 @@ export class TranscriptionSession extends EventEmitter {
|
|||
modelPath: modelInfo.modelPath,
|
||||
});
|
||||
|
||||
this.emit('chunk-completed', result);
|
||||
this.emit("chunk-completed", result);
|
||||
}
|
||||
|
||||
private completeSession(): void {
|
||||
|
|
@ -219,14 +230,17 @@ export class TranscriptionSession extends EventEmitter {
|
|||
const totalSessionTimeMs = sessionEndTime - this.sessionStartTime;
|
||||
const totalProcessingTime = this.results.reduce(
|
||||
(sum, result) => sum + result.processingTimeMs,
|
||||
0
|
||||
0,
|
||||
);
|
||||
|
||||
// Get model info from the last successful chunk result
|
||||
const lastChunkWithModel = this.results.find((r) => r.modelInfo);
|
||||
const sessionModelInfo = lastChunkWithModel?.modelInfo || { modelId: null, modelPath: null };
|
||||
const sessionModelInfo = lastChunkWithModel?.modelInfo || {
|
||||
modelId: null,
|
||||
modelPath: null,
|
||||
};
|
||||
|
||||
this.logger.error('Transcription session completed', {
|
||||
this.logger.error("Transcription session completed", {
|
||||
sessionId: this.sessionId,
|
||||
totalChunks: this.results.length,
|
||||
finalTextLength: this.accumulatedText.length,
|
||||
|
|
@ -237,9 +251,13 @@ export class TranscriptionSession extends EventEmitter {
|
|||
totalSessionTimeMs,
|
||||
totalProcessingTimeMs: totalProcessingTime,
|
||||
averageProcessingTimePerChunkMs:
|
||||
this.results.length > 0 ? Math.round(totalProcessingTime / this.results.length) : 0,
|
||||
this.results.length > 0
|
||||
? Math.round(totalProcessingTime / this.results.length)
|
||||
: 0,
|
||||
processingEfficiency:
|
||||
totalSessionTimeMs > 0 ? Math.round((totalProcessingTime / totalSessionTimeMs) * 100) : 0,
|
||||
totalSessionTimeMs > 0
|
||||
? Math.round((totalProcessingTime / totalSessionTimeMs) * 100)
|
||||
: 0,
|
||||
modelId: sessionModelInfo.modelId,
|
||||
modelPath: sessionModelInfo.modelPath,
|
||||
chunkTimings: this.results.map((r) => ({
|
||||
|
|
@ -251,7 +269,7 @@ export class TranscriptionSession extends EventEmitter {
|
|||
})),
|
||||
});
|
||||
|
||||
this.emit('session-completed', {
|
||||
this.emit("session-completed", {
|
||||
sessionId: this.sessionId,
|
||||
finalText: this.accumulatedText,
|
||||
chunkResults: this.results,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Waveform } from '../components/Waveform'; // Import Waveform
|
||||
import { useRecording, RecordingStatus } from '../hooks/useRecording'; // Import the hook and type
|
||||
import '@/styles/globals.css';
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Waveform } from "../components/Waveform"; // Import Waveform
|
||||
import { useRecording, RecordingStatus } from "../hooks/useRecording"; // Import the hook and type
|
||||
import "@/styles/globals.css";
|
||||
|
||||
const NUM_WAVEFORM_BARS = 8; // Fewer bars for a smaller button
|
||||
const DEBOUNCE_DELAY = 100; // milliseconds
|
||||
|
|
@ -12,51 +12,60 @@ const FloatingButtonApp: React.FC = () => {
|
|||
const fabRef = useRef<HTMLButtonElement>(null);
|
||||
const leaveTimeoutRef = useRef<NodeJS.Timeout | null>(null); // Ref for debounce timeout
|
||||
|
||||
const handleAudioChunk = useCallback(async (audioChunk: ArrayBuffer, isFinalChunk: boolean) => {
|
||||
try {
|
||||
// Send the audio chunk regardless of whether it's final or not
|
||||
await window.electronAPI.sendAudioChunk(audioChunk, isFinalChunk);
|
||||
console.log(`FAB: Sent audio chunk. isFinalChunk: ${isFinalChunk}`);
|
||||
const handleAudioChunk = useCallback(
|
||||
async (audioChunk: ArrayBuffer, isFinalChunk: boolean) => {
|
||||
try {
|
||||
// Send the audio chunk regardless of whether it's final or not
|
||||
await window.electronAPI.sendAudioChunk(audioChunk, isFinalChunk);
|
||||
console.log(`FAB: Sent audio chunk. isFinalChunk: ${isFinalChunk}`);
|
||||
|
||||
if (isFinalChunk) {
|
||||
console.log(
|
||||
'FAB: This was the final chunk. Informing main process to finalize transcription.'
|
||||
);
|
||||
// You might want to add a specific IPC call here if the main process needs an explicit signal
|
||||
// to finalize transcription, e.g., window.electronAPI.finalizeTranscription();
|
||||
// For now, we assume sendAudioChunk is enough and the main process handles the stream end.
|
||||
if (isFinalChunk) {
|
||||
console.log(
|
||||
"FAB: This was the final chunk. Informing main process to finalize transcription.",
|
||||
);
|
||||
// You might want to add a specific IPC call here if the main process needs an explicit signal
|
||||
// to finalize transcription, e.g., window.electronAPI.finalizeTranscription();
|
||||
// For now, we assume sendAudioChunk is enough and the main process handles the stream end.
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("FAB: Error sending audio chunk:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('FAB: Error sending audio chunk:', error);
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { recordingStatus, startRecording, stopRecording, voiceDetected } = useRecording({
|
||||
onAudioChunk: handleAudioChunk,
|
||||
onRecordingStartCallback: async () => await window.electronAPI.onRecordingStarting(),
|
||||
onRecordingStopCallback: async () => await window.electronAPI.onRecordingStopping(),
|
||||
// Optionally, set chunkDurationMs here if needed, e.g., chunkDurationMs: 250
|
||||
});
|
||||
const isRecording = recordingStatus === 'recording' || recordingStatus === 'starting';
|
||||
const isAwaitingFinalChunk = recordingStatus === 'stopping';
|
||||
console.log('FAB: recordingStatus:', recordingStatus);
|
||||
const { recordingStatus, startRecording, stopRecording, voiceDetected } =
|
||||
useRecording({
|
||||
onAudioChunk: handleAudioChunk,
|
||||
onRecordingStartCallback: async () =>
|
||||
await window.electronAPI.onRecordingStarting(),
|
||||
onRecordingStopCallback: async () =>
|
||||
await window.electronAPI.onRecordingStopping(),
|
||||
// Optionally, set chunkDurationMs here if needed, e.g., chunkDurationMs: 250
|
||||
});
|
||||
const isRecording =
|
||||
recordingStatus === "recording" || recordingStatus === "starting";
|
||||
const isAwaitingFinalChunk = recordingStatus === "stopping";
|
||||
console.log("FAB: recordingStatus:", recordingStatus);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.electronAPI.onRecordingStateChanged((newState: boolean) => {
|
||||
console.log('FAB: Received new recording state:', newState);
|
||||
if (newState) {
|
||||
startRecording();
|
||||
} else {
|
||||
stopRecording();
|
||||
}
|
||||
});
|
||||
const cleanup = window.electronAPI.onRecordingStateChanged(
|
||||
(newState: boolean) => {
|
||||
console.log("FAB: Received new recording state:", newState);
|
||||
if (newState) {
|
||||
startRecording();
|
||||
} else {
|
||||
stopRecording();
|
||||
}
|
||||
},
|
||||
);
|
||||
return cleanup; // Cleanup the listener when the component unmounts
|
||||
}, [startRecording, stopRecording]);
|
||||
|
||||
// This handler is for the button click.
|
||||
// It now uses the toggleRecording from the hook.
|
||||
const handleButtonClickToggleRecording = () => {
|
||||
console.log('FAB: Invoking toggleRecording from hook.');
|
||||
console.log("FAB: Invoking toggleRecording from hook.");
|
||||
// The hook internally manages starting/stopping MediaRecorder and VAD.
|
||||
// The hook also listens for global state changes from the main process.
|
||||
};
|
||||
|
|
@ -72,7 +81,7 @@ const FloatingButtonApp: React.FC = () => {
|
|||
|
||||
// Update window size when recording or hover state changes
|
||||
useEffect(() => {
|
||||
console.log('is hovered', isHovered);
|
||||
console.log("is hovered", isHovered);
|
||||
updateWindowSizeToFab();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isRecording, isHovered]);
|
||||
|
|
@ -106,9 +115,9 @@ const FloatingButtonApp: React.FC = () => {
|
|||
}, []);
|
||||
|
||||
const expanded =
|
||||
recordingStatus === 'recording' ||
|
||||
recordingStatus === 'starting' ||
|
||||
recordingStatus === 'stopping' ||
|
||||
recordingStatus === "recording" ||
|
||||
recordingStatus === "starting" ||
|
||||
recordingStatus === "stopping" ||
|
||||
isHovered;
|
||||
|
||||
return (
|
||||
|
|
@ -120,7 +129,7 @@ const FloatingButtonApp: React.FC = () => {
|
|||
onMouseLeave={handleMouseLeave}
|
||||
className={`
|
||||
transition-all duration-200 ease-in-out
|
||||
${expanded ? 'h-[32px] w-[96px]' : 'h-[16px] w-[48px]'}
|
||||
${expanded ? "h-[32px] w-[96px]" : "h-[16px] w-[48px]"}
|
||||
rounded-full border-2 border-text-muted bg-black/10 border-muted-foreground
|
||||
mb-2
|
||||
`}
|
||||
|
|
@ -131,7 +140,10 @@ const FloatingButtonApp: React.FC = () => {
|
|||
<Waveform
|
||||
key={index}
|
||||
index={index}
|
||||
isRecording={recordingStatus === 'recording' || recordingStatus === 'starting'}
|
||||
isRecording={
|
||||
recordingStatus === "recording" ||
|
||||
recordingStatus === "starting"
|
||||
}
|
||||
voiceDetected={voiceDetected} // Use local state for VAD
|
||||
baseHeight={100} // Percentage of its container (the 40% height div)
|
||||
silentHeight={20} // Percentage
|
||||
|
|
@ -143,10 +155,12 @@ const FloatingButtonApp: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const container = document.getElementById("root");
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(<FloatingButtonApp />);
|
||||
} else {
|
||||
console.error('FloatingButton: Root element not found in floating-button.html');
|
||||
console.error(
|
||||
"FloatingButton: Root element not found in floating-button.html",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,21 +26,25 @@
|
|||
* ```
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ipcLink } from 'electron-trpc-experimental/renderer';
|
||||
import superjson from 'superjson';
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { TranscriptionsView } from '@/components/transcriptions-view';
|
||||
import { VocabularyView } from '@/components/vocabulary-view';
|
||||
import { ModelsView } from '@/components/models-view';
|
||||
import { SettingsView } from '@/components/settings-view';
|
||||
import '@/styles/globals.css';
|
||||
import { SiteHeader } from '@/components/site-header';
|
||||
import { api } from '@/trpc/react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ipcLink } from "electron-trpc-experimental/renderer";
|
||||
import superjson from "superjson";
|
||||
import {
|
||||
SidebarProvider,
|
||||
SidebarInset,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { TranscriptionsView } from "@/components/transcriptions-view";
|
||||
import { VocabularyView } from "@/components/vocabulary-view";
|
||||
import { ModelsView } from "@/components/models-view";
|
||||
import { SettingsView } from "@/components/settings-view";
|
||||
import "@/styles/globals.css";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { api } from "@/trpc/react";
|
||||
|
||||
// import { Waveform } from '../components/Waveform'; // Waveform might not be needed if hook is removed
|
||||
// import { useRecording } from '../hooks/useRecording'; // Remove hook import
|
||||
|
|
@ -65,27 +69,27 @@ const trpcClient = api.createClient({
|
|||
const App: React.FC = () => {
|
||||
const [currentView, setCurrentView] = useState(() => {
|
||||
// Try to restore the view from localStorage, fallback to default
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('amical-current-view') || 'Voice Recording';
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("amical-current-view") || "Voice Recording";
|
||||
}
|
||||
return 'Voice Recording';
|
||||
return "Voice Recording";
|
||||
});
|
||||
|
||||
const handleNavigation = (item: any) => {
|
||||
setCurrentView(item.title);
|
||||
// Save to localStorage to preserve during HMR
|
||||
localStorage.setItem('amical-current-view', item.title);
|
||||
localStorage.setItem("amical-current-view", item.title);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (currentView) {
|
||||
case 'Transcriptions':
|
||||
case "Transcriptions":
|
||||
return <TranscriptionsView />;
|
||||
case 'Vocabulary':
|
||||
case "Vocabulary":
|
||||
return <VocabularyView />;
|
||||
case 'Models':
|
||||
case "Models":
|
||||
return <ModelsView />;
|
||||
case 'Settings':
|
||||
case "Settings":
|
||||
return <SettingsView />;
|
||||
default:
|
||||
return (
|
||||
|
|
@ -104,8 +108,8 @@ const App: React.FC = () => {
|
|||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': 'calc(var(--spacing) * 72)',
|
||||
'--header-height': 'calc(var(--spacing) * 12)',
|
||||
"--sidebar-width": "calc(var(--spacing) * 72)",
|
||||
"--header-height": "calc(var(--spacing) * 12)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
|
|
@ -125,8 +129,8 @@ const App: React.FC = () => {
|
|||
<div
|
||||
className="mx-auto w-full flex flex-col gap-4 md:gap-6"
|
||||
style={{
|
||||
maxWidth: 'var(--content-max-width)',
|
||||
padding: 'var(--content-padding)',
|
||||
maxWidth: "var(--content-max-width)",
|
||||
padding: "var(--content-padding)",
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
|
|
@ -143,7 +147,7 @@ const App: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const container = document.getElementById("root");
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(<App />);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica,
|
||||
Arial, sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
|
||||
sans-serif;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue