diff --git a/package-lock.json b/package-lock.json index 93d7d0f..ff7992b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "2.37.0", "license": "MIT", "dependencies": { + "@radix-ui/react-collapsible": "^1.1.12", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "dexie": "^4.0.11", "highlight.js": "^11.10.0", "i18next": "^25.5.2", @@ -27,7 +30,8 @@ "rehype-katex": "^7.0.1", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.0", - "remark-math": "^6.0.0" + "remark-math": "^6.0.0", + "tailwind-merge": "^3.3.1" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -2601,6 +2605,207 @@ "node": ">= 8" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3489,7 +3694,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -4293,6 +4498,27 @@ "node": ">=18" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -9874,6 +10100,16 @@ "node": ">=16.0.0" } }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", diff --git a/package.json b/package.json index 67d3994..56a9446 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "preview": "npm run build && vite preview" }, "dependencies": { + "@radix-ui/react-collapsible": "^1.1.12", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "dexie": "^4.0.11", "highlight.js": "^11.10.0", "i18next": "^25.5.2", @@ -35,7 +38,8 @@ "rehype-katex": "^7.0.1", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.0", - "remark-math": "^6.0.0" + "remark-math": "^6.0.0", + "tailwind-merge": "^3.3.1" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/src/App.tsx b/src/App.tsx index 10ace3c..5bbb3fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,18 +14,18 @@ import { Footer } from './components/Footer'; import Header from './components/Header'; import Sidebar from './components/Sidebar'; import { ToastPopup } from './components/ToastPopup'; -import { AppContextProvider, useAppContext } from './context/app'; -import { ChatContextProvider } from './context/chat'; +import { useDebouncedCallback } from './hooks/useDebouncedCallback'; +import { usePWAUpdatePrompt } from './hooks/usePWAUpdatePrompt'; +import ChatPage from './pages/Chat'; +import SettingsPage from './pages/Settings'; +import WelcomePage from './pages/Welcome'; +import { AppContextProvider, useAppContext } from './store/app'; +import { ChatContextProvider } from './store/chat'; import { InferenceContextProvider, useInferenceContext, -} from './context/inference'; -import { ModalProvider } from './context/modal'; -import { useDebouncedCallback } from './hooks/useDebouncedCallback'; -import { usePWAUpdatePrompt } from './hooks/usePWAUpdatePrompt'; -import ChatScreen from './pages/ChatScreen'; -import Settings from './pages/Settings'; -import WelcomeScreen from './pages/WelcomeScreen'; +} from './store/inference'; +import { ModalProvider } from './store/modal'; const DEBOUNCE_DELAY = 5000; const TOAST_IDS = { @@ -44,8 +44,8 @@ const App: FC = () => { }> } /> - } /> - } /> + } /> + } /> @@ -151,7 +151,7 @@ const AppLayout: FC = () => { const Chat: FC = () => { const { convId } = useParams(); if (!convId) return ; - return ; + return ; }; export default App; diff --git a/src/components/BtnWithTooltips.tsx b/src/components/BtnWithTooltips.tsx new file mode 100644 index 0000000..c0c680f --- /dev/null +++ b/src/components/BtnWithTooltips.tsx @@ -0,0 +1,49 @@ +import { Button } from './Button'; + +/** + * @deprecated Use `title` and `aria-label` props in button directly as utiliy classes. + * + * Wraps any button that needs a tooltip message. + * + * @param className - Optional additional classes to apply to the button + * @param onClick - Optional click handler for the container element + * @param onMouseLeave - Optional mouse leave handler for the inner button + * @param children - React node to render inside the button + * @param tooltipsContent - Text content to show in tooltip + * @param disabled - Whether the button should be disabled + */ +export function BtnWithTooltips({ + className, + onClick, + onMouseLeave, + children, + tooltipsContent, + disabled, +}: { + className?: string; + onClick?: () => void; + onMouseLeave?: () => void; + children: React.ReactNode; + tooltipsContent: string; + disabled?: boolean; +}) { + // the onClick handler is on the container, so screen readers can safely ignore the inner button + // this prevents the label from being read twice + return ( +
+ +
+ ); +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 0000000..8b9f0ba --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,44 @@ +import { cva, VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { cn } from '../utils'; + +const variants = cva('btn', { + variants: { + variant: { + default: '', + neutral: 'btn-neutral', + ghost: 'btn-ghost', + error: 'btn-error', + 'menu-item': 'btn-ghost border-none font-normal justify-start', + }, + size: { + default: '', + small: 'btn-sm', + icon: 'w-8 h-8 p-0', + 'icon-rounded': 'w-8 h-8 p-0 rounded-full', + 'icon-small': 'btn-sm w-4 h-4 p-0 rounded-full', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, +}); + +export type ButtonProps = React.ButtonHTMLAttributes & + VariantProps; + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => ( + -); - -/** - * Renders a link that opens in a new tab with proper security and accessibility attributes. - * - * @param href - URL to visit in new tab - * @param children - Visible link text - */ -export const OpenInNewTab = ({ - href, - children, -}: { - href: string; - children: string; -}) => ( - - {children} - -); - -export const downloadAsFile = (blobParts: BlobPart[], fileName: string) => { - const blob = new Blob(blobParts, { - type: 'application/json', - }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -}; - -/** - * @deprecated Use `title` and `aria-label` props in button directly as utiliy classes. - * - * Wraps any button that needs a tooltip message. - * - * @param className - Optional additional classes to apply to the button - * @param onClick - Optional click handler for the container element - * @param onMouseLeave - Optional mouse leave handler for the inner button - * @param children - React node to render inside the button - * @param tooltipsContent - Text content to show in tooltip - * @param disabled - Whether the button should be disabled - */ -export function BtnWithTooltips({ - className, - onClick, - onMouseLeave, - children, - tooltipsContent, - disabled, -}: { - className?: string; - onClick?: () => void; - onMouseLeave?: () => void; - children: React.ReactNode; - tooltipsContent: string; - disabled?: boolean; -}) { - // the onClick handler is on the container, so screen readers can safely ignore the inner button - // this prevents the label from being read twice - return ( -
- -
- ); -} - -interface IconButtonProps extends ButtonHTMLAttributes { - icon: FC<{ className?: string }>; - t: ReturnType['t']; - titleKey: string; - ariaLabelKey: string; -} -export const IntlIconButton = memo(function IntlIconButton({ - className, - disabled, - onClick, - icon: Icon, - t, - titleKey, - ariaLabelKey, - ...props -}: IconButtonProps) { - return ( - - ); -}); +import { Button } from './Button'; +import { Icon } from './Icon'; export interface DropdownOption { value: string | number; @@ -261,7 +113,7 @@ export function Dropdown({ > {currentValue} {!hideChevron && ( - + )} @@ -292,17 +144,18 @@ export function Dropdown({ > {filteredOptions.map((option) => (
  • - +
  • ))} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 4500703..a115798 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,11 +1,13 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { LuCog, LuMenu, LuSquarePen } from 'react-icons/lu'; import { useNavigate } from 'react-router'; -import { useAppContext } from '../context/app'; -import { useChatContext } from '../context/chat'; -import { useInferenceContext } from '../context/inference'; -import { Dropdown } from './common'; +import { useAppContext } from '../store/app'; +import { useChatContext } from '../store/chat'; +import { useInferenceContext } from '../store/inference'; +import { Button } from './Button'; +import { Dropdown } from './Dropdown'; +import { Icon } from './Icon'; +import { Label } from './Label'; export default function Header() { const navigate = useNavigate(); @@ -39,13 +41,14 @@ export default function Header() {
    {/* open sidebar button */} - + {/* spacer */} - + {/* new conversation button */} - + +
    {showSettings && (
    - +
    )} @@ -116,15 +120,17 @@ export default function Header() { {/* action buttons (top right) */}
    - + +
    )} diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx new file mode 100644 index 0000000..f769569 --- /dev/null +++ b/src/components/Icon.tsx @@ -0,0 +1,68 @@ +import { cva, VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { IconBaseProps } from 'react-icons'; +import * as LucideIcons from 'react-icons/lu'; +import { cn } from '../utils'; + +type IconNames = keyof Omit; + +const iconVariants = cva('', { + variants: { + library: { + lucide: 'lucide', + }, + variant: { + spin: 'animate-spin', + current: 'fill-current', + leftside: 'inline mr-1', + rightside: 'inline ml-1', + }, + size: { + xs: 'h-3 w-3', + sm: 'h-4 w-4', + md: 'h-5 w-5', + xl: 'h-8 w-8', + }, + }, + defaultVariants: { + library: 'lucide', + }, +}); + +type IconProps = Omit & + VariantProps & + React.SVGAttributes & { + className?: string; + icon: IconNames; + }; + +const Icon = React.forwardRef( + ({ className, library = 'lucide', variant, size, icon, ...props }, ref) => { + let SelectedIcon: React.ElementType; + + switch (library) { + case 'lucide': + SelectedIcon = LucideIcons[icon]; + break; + default: + throw new Error(`Library "${library}" not found in icons`); + } + + if (!SelectedIcon) { + throw new Error(`Icon "${icon}" not found in Lucide icons`); + } + + return ( + + ); + } +); + +Icon.displayName = 'Icon'; + +export default Icon; +export { Icon }; diff --git a/src/components/Label.tsx b/src/components/Label.tsx new file mode 100644 index 0000000..06a205b --- /dev/null +++ b/src/components/Label.tsx @@ -0,0 +1,44 @@ +import { cva, VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { cn } from '../utils'; + +const variants = cva('', { + variants: { + variant: { + default: '', + 'group-title': 'block font-bold text-base-content text-start', + 'fake-btn': 'text-center cursor-pointer', + btn: 'btn', + 'btn-ghost': 'btn btn-ghost', + 'form-control': 'form-control flex flex-col justify-center mb-3', + 'input-bordered': + 'input input-bordered join-item grow flex items-center gap-2 mb-1', + }, + size: { + default: '', + xs: 'text-xs', + icon: 'w-8 h-8 p-0', + 'icon-rounded': 'w-8 h-8 p-0 rounded-full', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, +}); + +export type LabelProps = React.LabelHTMLAttributes & + VariantProps; + +const Label = React.forwardRef( + ({ className, variant, size, ...props }, ref) => ( +