chore: formatting fixes

This commit is contained in:
haritabh-z01 2025-06-28 11:02:07 +05:30
parent dd6af5e879
commit 119a46c339
167 changed files with 4507 additions and 3248 deletions

View file

@ -7,6 +7,7 @@ out/
.vite/
dist/
build/
.next/
# Dependencies
node_modules/

2
.vscode/launch.json vendored
View file

@ -37,4 +37,4 @@
"preLaunchTask": "swift: Build Release SwiftHelper (packages/native-helpers/swift-helper)"
}
]
}
}

View file

@ -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"

View file

@ -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;

View file

@ -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

View file

@ -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>
);
};

View file

@ -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",
}}
/>
);

View file

@ -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>
)
);
}

View file

@ -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>
)
);
}

View file

@ -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>
);
};
};

View file

@ -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>
)
);
}

View file

@ -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>
)
);
}

View file

@ -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>
);
}
}

View file

@ -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>
)
);
}

View file

@ -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>
)
}
);
}

View file

@ -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>
)
}
);
}

View file

@ -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>
);
};
};

View file

@ -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 />;
}
}

View file

@ -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>
);
}

View file

@ -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}
/>
);

View file

@ -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}
/>

View file

@ -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} />;
}

View file

@ -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}
/>
);

View file

@ -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}
/>
);
}

View file

@ -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" />

View file

@ -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

View file

@ -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}

View file

@ -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,
};

View file

@ -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}

View file

@ -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 {

View file

@ -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}
>

View file

@ -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 };

View file

@ -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}
/>
);

View file

@ -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}
/>
);

View file

@ -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}
/>
);

View file

@ -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}
/>
);

View file

@ -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}
/>

View file

@ -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}

View file

@ -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}
/>

View file

@ -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 />

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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}
>

View file

@ -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" />

View file

@ -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} />;
}

View file

@ -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

View file

@ -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}
>

View file

@ -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}
>

View file

@ -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}
>

View file

@ -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" />

View file

@ -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}
/>

View file

@ -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}
/>
);

View file

@ -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}
/>

View file

@ -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}
/>
);

View file

@ -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>

View file

@ -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}

View file

@ -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>

View file

@ -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,
};

View file

@ -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}
/>
);

View file

@ -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}
/>

View file

@ -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}
>

View file

@ -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"

View file

@ -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}
>

View file

@ -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>
);
}
}

View file

@ -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>
)
}
);
}

View file

@ -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 />;
}
}

View file

@ -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",
},
];

View file

@ -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

View file

@ -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);

View file

@ -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> = {};

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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));
}

View file

@ -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();

View file

@ -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;

View file

@ -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,

View file

@ -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));

View file

@ -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);
}
}

View file

@ -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.",
);
}
}

View file

@ -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");
},
},
],

View file

@ -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();
});

View file

@ -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();
}
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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 {

View file

@ -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,

View file

@ -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;

View file

@ -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;
}

View file

@ -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";

View file

@ -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;
}

View file

@ -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),
});

View file

@ -1 +1 @@
export { SettingsService } from './settings-service';
export { SettingsService } from "./settings-service";

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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");
}
}

View file

@ -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,

View file

@ -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",
);
}

View file

@ -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 />);

View file

@ -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