feat(ai): AI reading assistant phase 2 (#3023)

* feat(ai): add dependencies

* chore: bump zod version to default version

* feat(ai): define types and model constants

* feat(ai): ollama provider for local LLM

* feat(ai): implement openrouter provider for cloud models

* feat(settings): register ai settings panel in global dialog

* refactor(ai): expose provider factory and service layer entry point

* test(ai): add unit tests for the providers

* test(ai): add unit tests for the providers

* feat(ai): settings panel for ai configurations

* refactor(ai): rewrite aipanel with autosave and greyed out disabled state

* fix: remove unused onClose prop from aipanel

* test(ai): update mock data

* refactor(ai): remove models

* refactor: use centralised defaults in system defaults

* chore(ai): remove comments

* fix(ai): merge default ai settings on load to prevent undefined values

* refactor(ai): rewrite settings panel with autosave and model input

* feat(ai): add ai tab with simplified highlighting

* feat(sidebar): render AIAssistant for ai tab

* feat(ai): add chat UI

* feat(ai); add chat service with RAG context

* feat(ai): temp debug logger

* feat(ai): add RAG service

* feat(ai): add text chunking utility

* feat(ai): add structured method

* feat(ai): add chatstructured method

* feat(ai): add rag types nd structured output schema

* feat(ai): add aistore, indexdb, bm25

* fix: update lock file

* feat(ai): update types for AI SDK v5

* feat(ai): add placeholder gateway model constants

* refactor(ai): update OllamaProvider for AI SDK

* feat(ai): add native gateway provider

* refactor(ai): update provider exports

* refactor(ai): use streamText from AI SDK

* refactor(ai): use embed from AI sdk

* refactor(ai): update provider factory exports

* feat(ai): add AI Elements and shadcn components

* config: add shadcn component config

* deps: add AI SDK and AI Elements dependencies

* config: add ai packages to transpilePackages

* refactor(ai): remove OpenRouterProvider and old tests

* feat(ai):add assistant-ui components

* feat(ai): add TauriChatAdapter for assistant-ui runtime

* refactor(ai): remove ai-elements components

* dep(ai): install assistant-ui and update next config

* chore(ai): export adapters from service index

* feat(ui): enhance ui components for assistant integration

* feat(settings): migrate ai settings to gateway

* feat(sidebar): integrate assistant-ui

* feat: add ai settings toggle to sidebar content

* feat: conditionally show ai tab in sidebar navigation

* feat: update ai model constants for cheaper options

* feat: add gateway provider with proxied embedding

* feat: add timeouts to ollama provider health checks

* feat: add retry logic to rag service embeddings

* feat: add error recovery to ai store

* feat: add ai feature tests

* feat: add ai api endpoints

* feat: add proxied gateway embedding provider

* feat: add ai runtime utilities

* feat: add ai retry utilities

* feat: add tauri env example template

* feat: add web env example template

* chore: add env

* feat(ai): update models and pricing, remove GLM-4.7-FlashX

* feat(ai): improve system prompt with official headings and no numeric citations

* feat(ai): optimize system prompt for tauri chat

* feat(ui): refine ai chat UI and relocate sources

* feat(ui): update ai settings panel with model pricing and custom model support

* feat(ai): add custom model support to ai settings

* test(ai): update constants tests for removed model

* feat(api): implement ai chat proxy route

* feat(api): implement ai embedding proxy route

* feat(ai): implement ai gateway health check and proxy logic

* feat(ai): simplify proxied embedding provider

* feat(ui): improve markdown text rendering

* feat(ui): add input group component

* test(ai): update ai provider tests

* feat(ai): add pageNumber to text chunk schema

* feat(ai): implement page-based chunking with 1500 char formula

* feat(ai): bump db to v2 and add store reset migration

* feat(ai): transition rag pipeline to page level spoiler filtering

* feat(ai): overhaul readest persona and antijailbreak prompt

* feat(ai): update tauri adapter for page tracking and persona

* chore(ai): export aiStore and logger from core index

* feat(reader): integrate page tracking and manual index reset

* feat(ui): add re-index action and reset logic to chat

* chore: sync pnpm lockfile with ai dependencies

* feat(utils): add browser-safe file utilities for web builds

* refactor(utils): use dynamic tauri fs import to prevent web crashes

* refactor(services): defer osType call to init() for web compatibility

* refactor(services): import RemoteFile from file.web

* refactor(services): import ClosableFile from file.web

* fix(libs): cast Entry to any for getData access

* fix(annotator): cast Overlayer to any for bubble access

* refactor(ai): replace SparklesIcon with BookOpenIcon for index prompt

* test(ai): add pageNumber to TextChunk mocks

* test(ai): fix chunkSection signature in tests

* chore: update files

* fix(ai): prevent useLocalRuntime crash when adapter is null

* refactor: optimize annotator overlay drawing

* feat: stabilize AI assistant runtime and adapter

* refactor: improve document zip loader type safety

* feat: update tauri chat adapter for dynamic options

* fix: restore architecture comments and refine platform properties

* build: update lockfile with assistant-ui patch

* fix(library): patch @assistant-ui/react for runtime initialization

* build: update dependencies in readest-app

* build: update root dependencies and patch configuration

* fix(ai): patch @assistant-ui/react for thread deletion and runtime init

* fix(ai): update assistant-ui patch with dist guards and deletion fallback

* build: sync lockfile with assistant-ui patch updates

* chore(env): update .gitignore by removing .env files from it

* chore(env): update .gitignore by adding .env.local

* chore(env): update .gitignore by adding .env*.local

* fix: restore static osType import

* chore: sync submodules with upstream/main

* refactor: remove redundant file.web module and revert import

* chore: update pnpm-lock.yaml

* refactor: revert guards

* refactor; remove deprecated codes and extract prompts.ts

* refactor(ai): remove unused ragservice exports

* refactor: remove unused ollama and embedding models

* refactor: remove unused type

* test: remove test for the now deleted constants

* refactor: remove unused export

* style: fix ui component formatting

* style: fix core and style file formatting

* test: fix broken ai provider import

* fix: typescript error

* fix: add eslint disable command

* fix(deps): remove unused ai sdk provider util after v6 ai sdk migration

* fix(patch): add lookbehind regex patch

* feat(dep): upgrade vercel ai sdk to v6 and ai-sdk-ollama to v3

* chore: update lockfile for vercel ai sdk v6

* refactor(ai): remove EmbeddingModel generic for ai sdk v6

* refactor(ai): remove EmbeddingModel generic for ai sdk v6

* test(ai): update mock to use embeddingModel

* fix(patch): add lookbehind regex patch for email autolinks in markdown

* refactor(ai): use ai sdk v6 syntax

* fix: prettier formatting

* chore: revert cargo.lock

* fix(ai): update proxied embedding model to v3 spec

* feat(ai): add aiconversation types for chat persistence

* feat(ai): add conversation/message indexeddb and crud operations

* feat(ai): create aiChatStore zustand store for chat state management

* feat(notebook): add notebookactivetab state for Notes/AI

* refactor(ai): refine conversation and message types for persistence

* feat(types): add notebookActiveTab to ReadSettings type

* chore: update deps

* feat: add notebookactive tab default value

* feat: add hook for ai chat

* feat: update left side panel with history/chat icon

* feat: integrate ChatHistoryView into sidebar content

* feat: create UI for managing AI chat history

* feat: implement persistent history with assistant-ui adapter

* feat: create tab navigation component for notes and AI

* feat: add tab navigation and AI assistant view

* feat: update header to display active tab title

* fix: formatting

* feat: remove title and update new chat button

* fix: formatting

* fix: revert tooltip and styling

* feat: implement cross-platform ask dialog bridge

* feat(ai): preserve history during ui clear & use native dialogs

* fix: align notebook navigation height with sidebar tabs

* fix(ai): add missing dependency to handleDeleteConversation hook

* docs: update PROJECT.md with session highlights

* chore: delete projectmd

* chore: update package.json and lock file

* chore: update package.json

* chore: remove patch

* chore: upgrade react types to 19 and show ai features only in development mode for now

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
Mohammed Efaz
2026-01-24 16:38:48 +06:00
committed by GitHub
parent 224acd68b1
commit 5bbc5ceccc
79 changed files with 12716 additions and 4607 deletions
+8 -2
View File
@@ -16,8 +16,13 @@ interface DropdownProps {
}>;
disabled?: boolean;
onToggle?: (isOpen: boolean) => void;
showTooltip?: boolean;
}
type MenuItemProps = {
setIsDropdownOpen?: (open: boolean) => void;
};
const enhanceMenuItems = (
children: ReactNode,
setIsDropdownOpen: (isOpen: boolean) => void,
@@ -27,7 +32,7 @@ const enhanceMenuItems = (
return node;
}
const element = node as ReactElement;
const element = node as React.ReactElement<React.PropsWithChildren<MenuItemProps>>;
const isMenuItem =
element.type === MenuItem ||
(typeof element.type === 'function' && element.type.name === 'MenuItem');
@@ -61,6 +66,7 @@ const Dropdown: React.FC<DropdownProps> = ({
children,
disabled,
onToggle,
showTooltip = true,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isFocused, setIsFocused] = useState(false);
@@ -137,7 +143,7 @@ const Dropdown: React.FC<DropdownProps> = ({
aria-haspopup='menu'
aria-expanded={isOpen}
aria-label={label}
title={label}
title={showTooltip ? label : undefined}
className={clsx(
'dropdown-toggle touch-target',
isFocused && isOpen && 'bg-base-300/50',
+1 -1
View File
@@ -10,7 +10,7 @@ interface MenuProps {
}
const Menu: React.FC<MenuProps> = ({ children, className, style, onCancel }) => {
const menuRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
useKeyDownActions({ onCancel, elementRef: menuRef });
@@ -8,7 +8,7 @@ import { useTranslation } from '@/hooks/useTranslation';
interface WindowButtonsProps {
className?: string;
headerRef?: React.RefObject<HTMLDivElement>;
headerRef?: React.RefObject<HTMLDivElement | null>;
showMinimize?: boolean;
showMaximize?: boolean;
showClose?: boolean;
@@ -0,0 +1,133 @@
'use client';
import '@assistant-ui/react-markdown/styles/dot.css';
import {
type CodeHeaderProps,
MarkdownTextPrimitive,
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
useIsMarkdownCodeBlock,
} from '@assistant-ui/react-markdown';
import remarkGfm from 'remark-gfm';
import { type FC, memo, useState } from 'react';
import { CheckIcon, CopyIcon } from 'lucide-react';
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button';
import { cn } from '@/lib/utils';
const MarkdownTextImpl = () => {
return (
<MarkdownTextPrimitive
remarkPlugins={[remarkGfm]}
className='aui-md'
components={defaultComponents}
/>
);
};
export const MarkdownText = memo(MarkdownTextImpl);
const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => {
const { isCopied, copyToClipboard } = useCopyToClipboard();
const onCopy = () => {
if (!code || isCopied) return;
copyToClipboard(code);
};
return (
<div className='bg-muted text-foreground mt-4 flex items-center justify-between gap-4 rounded-t-lg px-4 py-2 text-sm font-semibold'>
<span className='lowercase [&>span]:text-xs'>{language}</span>
<TooltipIconButton tooltip='Copy' onClick={onCopy}>
{!isCopied && <CopyIcon className='size-3' />}
{isCopied && <CheckIcon className='size-3' />}
</TooltipIconButton>
</div>
);
};
const useCopyToClipboard = ({ copiedDuration = 3000 }: { copiedDuration?: number } = {}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
const copyToClipboard = (value: string) => {
if (!value) return;
navigator.clipboard.writeText(value).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), copiedDuration);
});
};
return { isCopied, copyToClipboard };
};
const defaultComponents = memoizeMarkdownComponents({
h1: ({ className, ...props }) => (
// eslint-disable-next-line jsx-a11y/heading-has-content
<h1
className={cn('mb-4 scroll-m-20 text-2xl font-bold tracking-tight last:mb-0', className)}
{...props}
/>
),
h2: ({ className, ...props }) => (
// eslint-disable-next-line jsx-a11y/heading-has-content
<h2
className={cn(
'mb-3 mt-6 scroll-m-20 text-xl font-semibold tracking-tight first:mt-0 last:mb-0',
className,
)}
{...props}
/>
),
h3: ({ className, ...props }) => (
// eslint-disable-next-line jsx-a11y/heading-has-content
<h3
className={cn(
'mb-2 mt-4 scroll-m-20 text-lg font-semibold tracking-tight first:mt-0 last:mb-0',
className,
)}
{...props}
/>
),
p: ({ className, ...props }) => (
<p className={cn('mb-3 leading-7 first:mt-0 last:mb-0', className)} {...props} />
),
a: ({ className, ...props }) => (
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
className={cn('text-primary font-medium underline underline-offset-4', className)}
{...props}
/>
),
blockquote: ({ className, ...props }) => (
<blockquote className={cn('border-l-2 pl-4 italic', className)} {...props} />
),
ul: ({ className, ...props }) => (
<ul className={cn('my-3 ml-6 list-disc [&>li]:mt-1', className)} {...props} />
),
ol: ({ className, ...props }) => (
<ol className={cn('my-3 ml-6 list-decimal [&>li]:mt-1', className)} {...props} />
),
hr: ({ className, ...props }) => <hr className={cn('my-4 border-b', className)} {...props} />,
pre: ({ className, ...props }) => (
<pre
className={cn(
'overflow-x-auto rounded-b-lg rounded-t-none bg-zinc-900 p-4 text-sm text-zinc-100',
className,
)}
{...props}
/>
),
code: function Code({ className, ...props }) {
const isCodeBlock = useIsMarkdownCodeBlock();
return (
<code
className={cn(
!isCodeBlock && 'bg-muted rounded border px-1 py-0.5 font-mono text-sm',
className,
)}
{...props}
/>
);
},
CodeHeader,
});
@@ -0,0 +1,292 @@
'use client';
import type { FC } from 'react';
import {
ActionBarPrimitive,
AssistantIf,
BranchPickerPrimitive,
ComposerPrimitive,
MessagePrimitive,
ThreadPrimitive,
useAssistantState,
} from '@assistant-ui/react';
import {
ArrowUpIcon,
BookOpenIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
PencilIcon,
RefreshCwIcon,
SquareIcon,
Trash2Icon,
} from 'lucide-react';
import { MarkdownText } from '@/components/assistant-ui/markdown-text';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { ScoredChunk } from '@/services/ai/types';
interface ThreadProps {
sources?: ScoredChunk[];
onClear?: () => void;
onResetIndex?: () => void;
}
export const Thread: FC<ThreadProps> = ({ sources = [], onClear, onResetIndex }) => {
return (
<ThreadPrimitive.Root className='bg-base-100 flex h-full w-full flex-col items-stretch px-3'>
<ThreadPrimitive.Empty>
<div className='animate-in fade-in flex h-full flex-col items-center justify-center duration-300'>
<div className='bg-base-content/10 mb-4 rounded-full p-3'>
<BookOpenIcon className='text-base-content size-6' />
</div>
<h3 className='text-base-content mb-1 text-sm font-medium'>Ask about this book</h3>
<p className='text-base-content/60 mb-4 text-xs'>Get answers based on the book content</p>
<Composer onClear={onClear} onResetIndex={onResetIndex} />
</div>
</ThreadPrimitive.Empty>
<AssistantIf condition={(s) => s.thread.isEmpty === false}>
<ThreadPrimitive.Viewport className='flex grow flex-col overflow-y-auto scroll-smooth pt-2'>
<ThreadPrimitive.Messages
components={{
UserMessage,
EditComposer,
AssistantMessage: () => <AssistantMessage sources={sources} />,
}}
/>
<p className='text-base-content/40 mx-auto w-full p-1 text-center text-[10px]'>
AI can make mistakes. Verify with the book.
</p>
</ThreadPrimitive.Viewport>
<Composer onClear={onClear} onResetIndex={onResetIndex} />
</AssistantIf>
</ThreadPrimitive.Root>
);
};
interface ComposerProps {
onClear?: () => void;
onResetIndex?: () => void;
}
const Composer: FC<ComposerProps> = ({ onClear, onResetIndex }) => {
const isEmpty = useAssistantState((s) => s.composer.isEmpty);
const isRunning = useAssistantState((s) => s.thread.isRunning);
return (
<ComposerPrimitive.Root
className='group/composer animate-in fade-in slide-in-from-bottom-2 mx-auto mb-2 w-full duration-300'
data-empty={isEmpty}
data-running={isRunning}
>
<div className='bg-base-200 ring-base-content/10 focus-within:ring-base-content/20 overflow-hidden rounded-2xl shadow-sm ring-1 ring-inset transition-all duration-200'>
<div className='flex items-end gap-0.5 p-1.5'>
{onClear && (
<button
type='button'
onClick={onClear}
className='text-base-content hover:bg-base-300 mb-0.5 flex size-7 shrink-0 items-center justify-center rounded-full transition-colors'
aria-label='Clear chat'
>
<Trash2Icon className='size-3.5' />
</button>
)}
{onResetIndex && (
<button
type='button'
onClick={onResetIndex}
className='text-base-content hover:bg-base-300 mb-0.5 flex size-7 shrink-0 items-center justify-center rounded-full transition-colors'
title='Re-index book'
aria-label='Re-index book'
>
<RefreshCwIcon className='size-3.5' />
</button>
)}
<ComposerPrimitive.Input
placeholder='Ask about this book...'
rows={1}
className='text-base-content placeholder:text-base-content/40 my-1 h-5 max-h-[200px] min-w-0 flex-1 resize-none bg-transparent text-sm leading-5 outline-none'
/>
<div className='bg-base-content text-base-100 relative mb-0.5 size-7 shrink-0 rounded-full'>
<ComposerPrimitive.Send className='absolute inset-0 flex items-center justify-center transition-all duration-300 ease-out group-data-[empty=true]/composer:scale-0 group-data-[running=true]/composer:scale-0 group-data-[empty=true]/composer:opacity-0 group-data-[running=true]/composer:opacity-0'>
<ArrowUpIcon className='size-3.5' />
</ComposerPrimitive.Send>
<ComposerPrimitive.Cancel className='absolute inset-0 flex items-center justify-center transition-all duration-300 ease-out group-data-[running=false]/composer:scale-0 group-data-[running=false]/composer:opacity-0'>
<SquareIcon className='size-3' fill='currentColor' />
</ComposerPrimitive.Cancel>
{/* Placeholder when empty and not running */}
<div className='absolute inset-0 flex items-center justify-center transition-all duration-300 ease-out group-data-[empty=false]/composer:scale-0 group-data-[running=true]/composer:scale-0 group-data-[empty=false]/composer:opacity-0 group-data-[running=true]/composer:opacity-0'>
<ArrowUpIcon className='size-3.5 opacity-40' />
</div>
</div>
</div>
</div>
</ComposerPrimitive.Root>
);
};
interface AssistantMessageProps {
sources?: ScoredChunk[];
}
const AssistantMessage: FC<AssistantMessageProps> = ({ sources = [] }) => {
return (
<MessagePrimitive.Root className='group/message animate-in fade-in slide-in-from-bottom-1 relative mx-auto mb-1 flex w-full flex-col pb-0.5 duration-200'>
<div className='flex flex-col items-start'>
<div className='w-full max-w-none'>
<div className='prose prose-xs text-base-content [&_*]:!text-base-content [&_a]:!text-primary [&_code]:!text-base-content select-text text-sm'>
<MessagePrimitive.Parts components={{ Text: MarkdownText }} />
</div>
</div>
<AssistantIf condition={(s) => s.message.status?.type !== 'running'}>
<div className='animate-in fade-in mt-0.5 flex h-6 w-full items-center justify-start gap-0.5 duration-300'>
<ActionBarPrimitive.Root className='-ml-1 flex items-center gap-0.5'>
<BranchPicker />
{sources.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type='button'
className='text-base-content/40 hover:bg-base-200 hover:text-base-content flex size-6 items-center justify-center rounded-full transition-colors'
aria-label='View sources'
>
<BookOpenIcon className='size-3' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
className='bg-base-100 border-base-content/10 w-80 p-2'
>
<div className='text-base-content/60 mb-2 px-1 text-[11px] font-semibold'>
Sources from book
</div>
<div className='flex flex-col gap-1.5'>
{sources.map((source, i) => (
<div
key={source.id || i}
className='border-base-content/10 bg-base-200/50 rounded-lg border px-2 py-1.5 text-[11px]'
>
<div className='text-base-content font-medium'>
{source.chapterTitle || `Section ${source.sectionIndex + 1}`}
</div>
<div className='text-base-content/60 mt-0.5 line-clamp-3'>
{source.text}
</div>
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<ActionBarPrimitive.Reload className='text-base-content/40 hover:bg-base-200 hover:text-base-content flex size-6 items-center justify-center rounded-full transition-colors'>
<RefreshCwIcon className='size-3' />
</ActionBarPrimitive.Reload>
<ActionBarPrimitive.Copy className='text-base-content/40 hover:bg-base-200 hover:text-base-content flex size-6 items-center justify-center rounded-full transition-colors'>
<AssistantIf condition={({ message }) => message.isCopied}>
<CheckIcon className='size-3' />
</AssistantIf>
<AssistantIf condition={({ message }) => !message.isCopied}>
<CopyIcon className='size-3' />
</AssistantIf>
</ActionBarPrimitive.Copy>
</ActionBarPrimitive.Root>
</div>
</AssistantIf>
</div>
</MessagePrimitive.Root>
);
};
const UserMessage: FC = () => {
return (
<MessagePrimitive.Root className='group/message animate-in fade-in slide-in-from-bottom-1 relative mx-auto mb-1 flex w-full flex-col pb-0.5 duration-200'>
<div className='flex flex-col items-end'>
<div className='border-base-content/10 bg-base-200 text-base-content relative max-w-[90%] rounded-2xl rounded-br-md border px-3 py-2'>
<div className='prose prose-xs text-base-content [&_*]:!text-base-content select-text text-sm'>
<MessagePrimitive.Parts components={{ Text: MarkdownText }} />
</div>
</div>
<div className='mt-0.5 flex h-6 items-center justify-end gap-0.5'>
<ActionBarPrimitive.Root className='flex items-center gap-0.5'>
<ActionBarPrimitive.Edit className='text-base-content/40 hover:bg-base-200 hover:text-base-content flex size-6 items-center justify-center rounded-full transition-colors'>
<PencilIcon className='size-3' />
</ActionBarPrimitive.Edit>
<ActionBarPrimitive.Copy className='text-base-content/40 hover:bg-base-200 hover:text-base-content flex size-6 items-center justify-center rounded-full transition-colors'>
<AssistantIf condition={({ message }) => message.isCopied}>
<CheckIcon className='size-3' />
</AssistantIf>
<AssistantIf condition={({ message }) => !message.isCopied}>
<CopyIcon className='size-3' />
</AssistantIf>
</ActionBarPrimitive.Copy>
</ActionBarPrimitive.Root>
</div>
</div>
</MessagePrimitive.Root>
);
};
const EditComposer: FC = () => {
return (
<MessagePrimitive.Root className='mx-auto flex w-full flex-col py-2'>
<ComposerPrimitive.Root className='border-base-content/10 bg-base-200 ml-auto flex w-full max-w-[90%] flex-col overflow-hidden rounded-2xl border'>
<ComposerPrimitive.Input className='text-base-content min-h-10 w-full resize-none bg-transparent p-3 text-sm outline-none' />
<div className='mx-2 mb-2 flex items-center gap-1.5 self-end'>
<ComposerPrimitive.Cancel asChild>
<Button variant='ghost' size='sm' className='h-7 px-2 text-xs'>
Cancel
</Button>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<Button size='sm' className='h-7 px-2 text-xs'>
Update
</Button>
</ComposerPrimitive.Send>
</div>
</ComposerPrimitive.Root>
</MessagePrimitive.Root>
);
};
const BranchPicker: FC<{ className?: string }> = ({ className }) => {
return (
<BranchPickerPrimitive.Root
hideWhenSingleBranch
className={cn('text-base-content/40 mr-0.5 inline-flex items-center text-[10px]', className)}
>
<BranchPickerPrimitive.Previous asChild>
<button
type='button'
className='hover:bg-base-200 hover:text-base-content flex size-6 items-center justify-center rounded-full transition-colors'
>
<ChevronLeftIcon className='size-3' />
</button>
</BranchPickerPrimitive.Previous>
<span className='font-medium'>
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild>
<button
type='button'
className='hover:bg-base-200 hover:text-base-content flex size-6 items-center justify-center rounded-full transition-colors'
>
<ChevronRightIcon className='size-3' />
</button>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
};
@@ -0,0 +1,38 @@
'use client';
import { forwardRef, type ComponentPropsWithRef } from 'react';
import { Slottable } from '@radix-ui/react-slot';
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {
tooltip: string;
side?: 'top' | 'bottom' | 'left' | 'right';
};
export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>(
({ children, tooltip, side = 'bottom', className, ...rest }, ref) => {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon-sm'
{...rest}
className={cn('size-6 p-1', className)}
ref={ref}
>
<Slottable>{children}</Slottable>
<span className='sr-only'>{tooltip}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={side}>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
);
TooltipIconButton.displayName = 'TooltipIconButton';
@@ -0,0 +1,528 @@
import clsx from 'clsx';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { PiCheckCircle, PiWarningCircle, PiArrowsClockwise, PiSpinner } from 'react-icons/pi';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useEnv } from '@/context/EnvContext';
import { getAIProvider } from '@/services/ai/providers';
import { DEFAULT_AI_SETTINGS, GATEWAY_MODELS, MODEL_PRICING } from '@/services/ai/constants';
import type { AISettings, AIProviderName } from '@/services/ai/types';
type ConnectionStatus = 'idle' | 'testing' | 'success' | 'error';
type CustomModelStatus = 'idle' | 'validating' | 'valid' | 'invalid';
const CUSTOM_MODEL_VALUE = '__custom__';
interface ModelOption {
id: string;
label: string;
inputCost: string;
outputCost: string;
}
const getModelOptions = (): ModelOption[] => [
{
id: GATEWAY_MODELS.GEMINI_FLASH_LITE,
label: 'Gemini 2.5 Flash Lite',
inputCost: MODEL_PRICING[GATEWAY_MODELS.GEMINI_FLASH_LITE]?.input ?? '?',
outputCost: MODEL_PRICING[GATEWAY_MODELS.GEMINI_FLASH_LITE]?.output ?? '?',
},
{
id: GATEWAY_MODELS.GPT_5_NANO,
label: 'GPT-5 Nano',
inputCost: MODEL_PRICING[GATEWAY_MODELS.GPT_5_NANO]?.input ?? '?',
outputCost: MODEL_PRICING[GATEWAY_MODELS.GPT_5_NANO]?.output ?? '?',
},
{
id: GATEWAY_MODELS.LLAMA_4_SCOUT,
label: 'Llama 4 Scout',
inputCost: MODEL_PRICING[GATEWAY_MODELS.LLAMA_4_SCOUT]?.input ?? '?',
outputCost: MODEL_PRICING[GATEWAY_MODELS.LLAMA_4_SCOUT]?.output ?? '?',
},
{
id: GATEWAY_MODELS.GROK_4_1_FAST,
label: 'Grok 4.1 Fast',
inputCost: MODEL_PRICING[GATEWAY_MODELS.GROK_4_1_FAST]?.input ?? '?',
outputCost: MODEL_PRICING[GATEWAY_MODELS.GROK_4_1_FAST]?.output ?? '?',
},
{
id: GATEWAY_MODELS.DEEPSEEK_V3_2,
label: 'DeepSeek V3.2',
inputCost: MODEL_PRICING[GATEWAY_MODELS.DEEPSEEK_V3_2]?.input ?? '?',
outputCost: MODEL_PRICING[GATEWAY_MODELS.DEEPSEEK_V3_2]?.output ?? '?',
},
{
id: GATEWAY_MODELS.QWEN_3_235B,
label: 'Qwen 3 235B',
inputCost: MODEL_PRICING[GATEWAY_MODELS.QWEN_3_235B]?.input ?? '?',
outputCost: MODEL_PRICING[GATEWAY_MODELS.QWEN_3_235B]?.output ?? '?',
},
];
const AIPanel: React.FC = () => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings, setSettings, saveSettings } = useSettingsStore();
const aiSettings: AISettings = settings?.aiSettings ?? DEFAULT_AI_SETTINGS;
const [enabled, setEnabled] = useState(aiSettings.enabled);
const [provider, setProvider] = useState<AIProviderName>(aiSettings.provider);
const [ollamaUrl, setOllamaUrl] = useState(aiSettings.ollamaBaseUrl);
const [ollamaModel, setOllamaModel] = useState(aiSettings.ollamaModel);
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [fetchingModels, setFetchingModels] = useState(false);
const [gatewayKey, setGatewayKey] = useState(aiSettings.aiGatewayApiKey ?? '');
const savedCustomModel = aiSettings.aiGatewayCustomModel ?? '';
const savedModel = aiSettings.aiGatewayModel ?? DEFAULT_AI_SETTINGS.aiGatewayModel ?? '';
const isCustomModelSaved = savedCustomModel.length > 0;
const [selectedModel, setSelectedModel] = useState(
isCustomModelSaved ? CUSTOM_MODEL_VALUE : savedModel,
);
const [customModelInput, setCustomModelInput] = useState(savedCustomModel);
const [customModelStatus, setCustomModelStatus] = useState<CustomModelStatus>(
isCustomModelSaved ? 'valid' : 'idle',
);
const [customModelPricing, setCustomModelPricing] = useState<{
input: string;
output: string;
} | null>(isCustomModelSaved ? { input: '?', output: '?' } : null);
const [customModelError, setCustomModelError] = useState('');
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('idle');
const [errorMessage, setErrorMessage] = useState('');
const isMounted = useRef(false);
const modelOptions = getModelOptions();
const settingsRef = useRef(settings);
useEffect(() => {
settingsRef.current = settings;
}, [settings]);
const saveAiSetting = useCallback(
async (key: keyof AISettings, value: AISettings[keyof AISettings]) => {
const currentSettings = settingsRef.current;
if (!currentSettings) return;
const currentAiSettings: AISettings = currentSettings.aiSettings ?? DEFAULT_AI_SETTINGS;
const newAiSettings: AISettings = { ...currentAiSettings, [key]: value };
const newSettings = { ...currentSettings, aiSettings: newAiSettings };
setSettings(newSettings);
await saveSettings(envConfig, newSettings);
},
[envConfig, setSettings, saveSettings],
);
const fetchOllamaModels = useCallback(async () => {
if (!ollamaUrl || !enabled) return;
setFetchingModels(true);
try {
const response = await fetch(`${ollamaUrl}/api/tags`);
if (!response.ok) throw new Error('Failed to fetch models');
const data = await response.json();
const models = data.models?.map((m: { name: string }) => m.name) || [];
setOllamaModels(models);
if (models.length > 0 && !models.includes(ollamaModel)) {
setOllamaModel(models[0]!);
}
} catch (_err) {
setOllamaModels([]);
} finally {
setFetchingModels(false);
}
}, [ollamaUrl, ollamaModel, enabled]);
useEffect(() => {
if (provider === 'ollama' && enabled) {
fetchOllamaModels();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider, enabled, ollamaUrl]);
useEffect(() => {
isMounted.current = true;
}, []);
useEffect(() => {
if (!isMounted.current) return;
if (enabled !== aiSettings.enabled) {
saveAiSetting('enabled', enabled);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled]);
useEffect(() => {
if (!isMounted.current) return;
if (provider !== aiSettings.provider) {
saveAiSetting('provider', provider);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider]);
useEffect(() => {
if (!isMounted.current) return;
if (ollamaUrl !== aiSettings.ollamaBaseUrl) {
saveAiSetting('ollamaBaseUrl', ollamaUrl);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ollamaUrl]);
useEffect(() => {
if (!isMounted.current) return;
if (ollamaModel !== aiSettings.ollamaModel) {
saveAiSetting('ollamaModel', ollamaModel);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ollamaModel]);
useEffect(() => {
if (!isMounted.current) return;
if (gatewayKey !== (aiSettings.aiGatewayApiKey ?? '')) {
saveAiSetting('aiGatewayApiKey', gatewayKey);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gatewayKey]);
// Get the effective model ID to use (either selected or custom)
const getEffectiveModelId = useCallback(() => {
if (selectedModel === CUSTOM_MODEL_VALUE && customModelStatus === 'valid') {
return customModelInput;
}
return selectedModel;
}, [selectedModel, customModelStatus, customModelInput]);
// Save model selection when it changes
useEffect(() => {
if (!isMounted.current) return;
const effectiveModel = getEffectiveModelId();
if (effectiveModel !== aiSettings.aiGatewayModel) {
saveAiSetting('aiGatewayModel', effectiveModel);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModel, customModelStatus, customModelInput]);
// Save custom model separately
useEffect(() => {
if (!isMounted.current) return;
const customToSave =
selectedModel === CUSTOM_MODEL_VALUE && customModelStatus === 'valid' ? customModelInput : '';
if (customToSave !== (aiSettings.aiGatewayCustomModel ?? '')) {
saveAiSetting('aiGatewayCustomModel', customToSave);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModel, customModelStatus, customModelInput]);
const handleModelChange = (value: string) => {
setSelectedModel(value);
if (value !== CUSTOM_MODEL_VALUE) {
setCustomModelStatus('idle');
setCustomModelError('');
setCustomModelPricing(null);
}
};
const validateCustomModel = async () => {
if (!customModelInput.trim()) {
setCustomModelError(_('Please enter a model ID'));
setCustomModelStatus('invalid');
return;
}
setCustomModelStatus('validating');
setCustomModelError('');
try {
// Simple validation: try to make a minimal request to verify model exists
// This uses the AI Gateway to check if the model is available
const testSettings: AISettings = {
...aiSettings,
provider: 'ai-gateway',
aiGatewayApiKey: gatewayKey,
aiGatewayModel: customModelInput.trim(),
};
const aiProvider = getAIProvider(testSettings);
const isAvailable = await aiProvider.isAvailable();
if (isAvailable) {
setCustomModelStatus('valid');
// Set unknown pricing for custom models
setCustomModelPricing({ input: '?', output: '?' });
} else {
setCustomModelStatus('invalid');
setCustomModelError(_('Model not available or invalid'));
}
} catch (_err) {
setCustomModelStatus('invalid');
setCustomModelError(_('Failed to validate model'));
}
};
const handleTestConnection = async () => {
if (!enabled) return;
setConnectionStatus('testing');
setErrorMessage('');
try {
const effectiveModel = getEffectiveModelId();
const testSettings: AISettings = {
...aiSettings,
provider,
ollamaBaseUrl: ollamaUrl,
ollamaModel,
aiGatewayApiKey: gatewayKey,
aiGatewayModel: effectiveModel,
};
const aiProvider = getAIProvider(testSettings);
const isHealthy = await aiProvider.healthCheck();
if (isHealthy) {
setConnectionStatus('success');
} else {
setConnectionStatus('error');
setErrorMessage(
provider === 'ollama'
? _("Couldn't connect to Ollama. Is it running?")
: _('Invalid API key or connection failed'),
);
}
} catch (error) {
setConnectionStatus('error');
setErrorMessage((error as Error).message || _('Connection failed'));
}
};
const disabledSection = !enabled ? 'opacity-50 pointer-events-none select-none' : '';
return (
<div className='my-4 w-full space-y-6'>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('AI Assistant')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span>{_('Enable AI Assistant')}</span>
<input
type='checkbox'
className='toggle'
checked={enabled}
onChange={() => setEnabled(!enabled)}
/>
</div>
</div>
</div>
</div>
<div className={clsx('w-full', disabledSection)}>
<h2 className='mb-2 font-medium'>{_('Provider')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span>{_('Ollama (Local)')}</span>
<input
type='radio'
name='ai-provider'
className='radio'
checked={provider === 'ollama'}
onChange={() => setProvider('ollama')}
disabled={!enabled}
/>
</div>
<div className='config-item'>
<span>{_('AI Gateway (Cloud)')}</span>
<input
type='radio'
name='ai-provider'
className='radio'
checked={provider === 'ai-gateway'}
onChange={() => setProvider('ai-gateway')}
disabled={!enabled}
/>
</div>
</div>
</div>
</div>
{provider === 'ollama' && (
<div className={clsx('w-full', disabledSection)}>
<h2 className='mb-2 font-medium'>{_('Ollama Configuration')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item !h-auto flex-col !items-start gap-2 py-3'>
<div className='flex w-full items-center justify-between'>
<span>{_('Server URL')}</span>
<button
className='btn btn-ghost btn-xs'
onClick={fetchOllamaModels}
disabled={!enabled || fetchingModels}
title={_('Refresh Models')}
>
<PiArrowsClockwise className='size-4' />
</button>
</div>
<input
type='text'
className='input input-bordered input-sm w-full'
value={ollamaUrl}
onChange={(e) => setOllamaUrl(e.target.value)}
placeholder='http://127.0.0.1:11434'
disabled={!enabled}
/>
</div>
{ollamaModels.length > 0 ? (
<div className='config-item !h-auto flex-col !items-start gap-2 py-3'>
<span>{_('AI Model')}</span>
<select
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
value={ollamaModel}
onChange={(e) => setOllamaModel(e.target.value)}
disabled={!enabled}
>
{ollamaModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
) : !fetchingModels ? (
<div className='config-item'>
<span className='text-warning text-sm'>{_('No models detected')}</span>
</div>
) : null}
</div>
</div>
</div>
)}
{provider === 'ai-gateway' && (
<div className={clsx('w-full', disabledSection)}>
<h2 className='mb-2 font-medium'>{_('AI Gateway Configuration')}</h2>
<p className='text-base-content/70 mb-3 text-sm'>
{_(
'Choose from a selection of high-quality, economical AI models. You can also bring your own model by selecting "Custom Model" below.',
)}
</p>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item !h-auto flex-col !items-start gap-2 py-3'>
<div className='flex w-full items-center justify-between'>
<span>{_('API Key')}</span>
<a
href='https://vercel.com/docs/ai/ai-gateway'
target='_blank'
rel='noopener noreferrer'
className={clsx('link text-xs', !enabled && 'pointer-events-none')}
>
{_('Get Key')}
</a>
</div>
<input
type='password'
className='input input-bordered input-sm w-full'
value={gatewayKey}
onChange={(e) => setGatewayKey(e.target.value)}
placeholder='vck_...'
disabled={!enabled}
/>
</div>
<div className='config-item !h-auto flex-col !items-start gap-2 py-3'>
<span>{_('Model')}</span>
<select
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
value={selectedModel}
onChange={(e) => handleModelChange(e.target.value)}
disabled={!enabled}
>
{modelOptions.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.label} ${opt.inputCost}/M in, ${opt.outputCost}/M out
</option>
))}
<option value={CUSTOM_MODEL_VALUE}>{_('Custom Model...')}</option>
</select>
</div>
{selectedModel === CUSTOM_MODEL_VALUE && (
<div className='config-item !h-auto flex-col !items-start gap-2 py-3'>
<span>{_('Custom Model ID')}</span>
<div className='flex w-full gap-2'>
<input
type='text'
className='input input-bordered input-sm flex-1'
value={customModelInput}
onChange={(e) => {
setCustomModelInput(e.target.value);
setCustomModelStatus('idle');
setCustomModelError('');
}}
placeholder='provider/model-name'
disabled={!enabled}
/>
<button
className='btn btn-outline btn-sm'
onClick={validateCustomModel}
disabled={!enabled || customModelStatus === 'validating'}
>
{customModelStatus === 'validating' ? (
<PiSpinner className='size-4 animate-spin' />
) : (
_('Validate')
)}
</button>
</div>
{customModelStatus === 'valid' && customModelPricing && (
<span className='text-success flex items-center gap-1 text-sm'>
<PiCheckCircle />
{_('Model available')} ${customModelPricing.input}/M in, $
{customModelPricing.output}/M out
</span>
)}
{customModelStatus === 'invalid' && (
<span className='text-error text-sm'>{customModelError}</span>
)}
</div>
)}
</div>
</div>
</div>
)}
<div className={clsx('w-full', disabledSection)}>
<h2 className='mb-2 font-medium'>{_('Connection')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<button
className='btn btn-outline btn-sm'
onClick={handleTestConnection}
disabled={!enabled || connectionStatus === 'testing'}
>
{_('Test Connection')}
</button>
{connectionStatus === 'success' && (
<span className='text-success flex items-center gap-1 text-sm'>
<PiCheckCircle />
{_('Connected')}
</span>
)}
{connectionStatus === 'error' && (
<span className='text-error flex items-center gap-1 text-sm'>
<PiWarningCircle />
{errorMessage || _('Failed')}
</span>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default AIPanel;
@@ -123,7 +123,7 @@ const MiscPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
e.nativeEvent.stopImmediatePropagation();
};
const handleInputFocus = (textareaRef: React.RefObject<HTMLTextAreaElement>) => {
const handleInputFocus = (textareaRef: React.RefObject<HTMLTextAreaElement | null>) => {
if (appService?.isAndroidApp) {
setInputFocusInAndroid(true);
}
@@ -150,7 +150,7 @@ const MiscPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
value: string,
error: string | null,
saved: boolean,
textareaRef: React.RefObject<HTMLTextAreaElement>,
textareaRef: React.RefObject<HTMLTextAreaElement | null>,
) => (
<div className='w-full'>
<h2 className='mb-2 font-medium' aria-label={_(title)}>
@@ -7,7 +7,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { RiFontSize } from 'react-icons/ri';
import { RiDashboardLine, RiTranslate } from 'react-icons/ri';
import { VscSymbolColor } from 'react-icons/vsc';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { PiDotsThreeVerticalBold, PiRobot } from 'react-icons/pi';
import { LiaHandPointerSolid } from 'react-icons/lia';
import { IoAccessibilityOutline } from 'react-icons/io5';
import { MdArrowBackIosNew, MdArrowForwardIos, MdClose } from 'react-icons/md';
@@ -21,8 +21,16 @@ import DialogMenu from './DialogMenu';
import ControlPanel from './ControlPanel';
import LangPanel from './LangPanel';
import MiscPanel from './MiscPanel';
import AIPanel from './AIPanel';
export type SettingsPanelType = 'Font' | 'Layout' | 'Color' | 'Control' | 'Language' | 'Custom';
export type SettingsPanelType =
| 'Font'
| 'Layout'
| 'Color'
| 'Control'
| 'Language'
| 'AI'
| 'Custom';
export type SettingsPanelPanelProp = {
bookKey: string;
onRegisterReset: (resetFn: () => void) => void;
@@ -32,6 +40,7 @@ type TabConfig = {
tab: SettingsPanelType;
icon: React.ElementType;
label: string;
disabled?: boolean;
};
const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
@@ -69,6 +78,12 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
icon: RiTranslate,
label: _('Language'),
},
{
tab: 'AI',
icon: PiRobot,
label: _('AI Assistant'),
disabled: process.env.NODE_ENV === 'production',
},
{
tab: 'Custom',
icon: IoAccessibilityOutline,
@@ -98,6 +113,7 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
Color: null,
Control: null,
Language: null,
AI: null,
Custom: null,
});
@@ -198,29 +214,31 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
)}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{tabConfig.map(({ tab, icon: Icon, label }) => (
<button
key={tab}
data-tab={tab}
tabIndex={0}
title={label}
className={clsx(
'btn btn-ghost text-base-content btn-sm gap-1 px-2 max-[350px]:px-1',
activePanel === tab ? 'btn-active' : '',
)}
onClick={() => handleSetActivePanel(tab)}
>
<Icon className='mr-0' />
<span
{tabConfig
.filter((t) => !t.disabled)
.map(({ tab, icon: Icon, label }) => (
<button
key={tab}
data-tab={tab}
tabIndex={0}
title={label}
className={clsx(
window.innerWidth < 640 && 'hidden',
!(showAllTabLabels || activePanel === tab) && 'hidden',
'btn btn-ghost text-base-content btn-sm gap-1 px-2 max-[350px]:px-1',
activePanel === tab ? 'btn-active' : '',
)}
onClick={() => handleSetActivePanel(tab)}
>
{label}
</span>
</button>
))}
<Icon className='mr-0' />
<span
className={clsx(
window.innerWidth < 640 && 'hidden',
!(showAllTabLabels || activePanel === tab) && 'hidden',
)}
>
{label}
</span>
</button>
))}
</div>
<div className='flex h-full items-center justify-end gap-x-2'>
<Dropdown
@@ -284,6 +302,7 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onRegisterReset={(fn) => registerResetFunction('Language', fn)}
/>
)}
{activePanel === 'AI' && <AIPanel />}
{activePanel === 'Custom' && (
<MiscPanel
bookKey={bookKey}
@@ -0,0 +1,78 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Separator } from '@/components/ui/separator';
const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
vertical:
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
},
},
defaultVariants: {
orientation: 'horizontal',
},
},
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role='group'
data-slot='button-group'
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
className={cn(
"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot='button-group-separator'
orientation={orientation}
className={cn(
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
className,
)}
{...props}
/>
);
}
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
@@ -0,0 +1,50 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
'icon-sm': 'h-7 w-7',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
@@ -0,0 +1,11 @@
'use client';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
@@ -0,0 +1,143 @@
'use client';
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent } from '@/components/ui/dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className='overflow-hidden p-0'>
<Command className='[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5'>
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className='flex items-center border-b px-3' cmdk-input-wrapper=''>
<Search className='mr-2 h-4 w-4 shrink-0 opacity-50' />
<CommandPrimitive.Input
ref={ref}
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className='py-6 text-center text-sm' {...props} />
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
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,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
@@ -0,0 +1,104 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
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/80',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className='ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none'>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
@@ -0,0 +1,188 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className='ml-auto' />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'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}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Check className='h-4 w-4' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Circle className='h-2 w-2 fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('bg-muted -mx-1 my-1 h-px', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from '@/lib/utils';
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
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-none',
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
@@ -0,0 +1,165 @@
'use client';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='input-group'
role='group'
className={cn(
'group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]',
'h-9 has-[>textarea]:h-auto',
// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1',
// Error state.
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]',
'block-start':
'[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5',
'block-end':
'[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5',
},
},
defaultVariants: {
align: 'inline-start',
},
},
);
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<div
role='group'
data-slot='input-group-addon'
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return;
}
e.currentTarget.parentElement?.querySelector('input')?.focus();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.currentTarget.parentElement?.querySelector('input')?.focus();
}
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
});
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
)}
{...props}
/>
);
}
function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
return (
<Input
data-slot='input-group-control'
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot='input-group-control'
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};
@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'border-input file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };
@@ -0,0 +1,152 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'border-input ring-offset-background data-[placeholder]:text-muted-foreground focus:ring-ring flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className='h-4 w-4 opacity-50' />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className='h-4 w-4' />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className='h-4 w-4' />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
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-y-auto overflow-x-hidden 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}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className='absolute right-2 flex h-3.5 w-3.5 items-center justify-center'>
<SelectPrimitive.ItemIndicator>
<Check className='h-4 w-4' />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('bg-muted -mx-1 my-1 h-px', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
@@ -0,0 +1,26 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-sm focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';
export { Textarea };
@@ -0,0 +1,53 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = ({
children,
...props
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Provider>) => (
<TooltipPrimitive.Provider delayDuration={400} skipDelayDuration={100} {...props}>
{children}
</TooltipPrimitive.Provider>
);
const Tooltip = ({
children,
...props
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Root>) => (
<TooltipPrimitive.Root disableHoverableContent {...props}>
{children}
</TooltipPrimitive.Root>
);
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, children, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 w-fit rounded-md bg-white px-3 py-1.5 text-xs text-neutral-900 shadow-md',
'origin-[var(--radix-tooltip-content-transform-origin)]',
'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',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className='fill-white' />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };