mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
📦 feat: Move Shared Components to @librechat/client (#8685)
* feat: init @librechat/client * feat: Add common types and interfaces for accessibility, agents, artifacts, assistants, and tools * feat: Add jotai as a peer dependency * fix build client package * feat: cleanup unused types from common/index.ts - Remove 104 unused type exports from packages/client/src/common/index.ts - Keep only 7 actually used exports (93% reduction) - Add cleanup script with enhanced import pattern detection - Support both named imports and namespace imports (* as t) - Create automatic backups and comprehensive documentation - Maintain type safety with build verification - No breaking changes to existing code Kept exports: - TShowToast, Option, OptionWithIcon, DropdownValueSetter - MentionOption, NotificationSeverity, MenuItemProps Scripts: cleanup-common-types-safe.js, README-CLEANUP.md * fix: cleanup * fix: package; refactor: tsconfig * feat: add back `recoil` * fix: move dependencies to peerDependencies in client package * feat: add @librechat/client as a dependency in package.json and package-lock.json * feat: update client package configuration and dependencies - Added new dependencies for Rollup plugins and updated existing ones in package.json and package-lock.json. - Introduced a new Rollup configuration file for building the client package. - Refactored build scripts to include a dedicated build command for the client. - Updated TypeScript configuration for improved module resolution and type declaration output. - Integrated a Toast component from the client package into the main App component. * feat: enhance Rollup configuration for client package - Updated terser plugin settings to preserve directives like 'use client'. - Added custom warning handler to ignore "use client" directive warnings during the build process. * chore: rename package/client build script command * feat: update client package dependencies and Rollup configuration - Added rollup-plugin-postcss to package.json and updated package-lock.json. - Enhanced Rollup configuration to include postcss plugin for CSS handling. - Updated index.ts to export all components from the components directory for better modularity. * feat: add client package directory to update configuration - Included the 'client' package directory in the update.js configuration to ensure it is recognized during updates. * feat: export Toast component in client package - Added export for the Toast component in index.ts to enhance modularity and accessibility of components. * feat: /client transition to @librechat/client * chore: fixed formatting issues * fix: update peer dependencies in @librechat/client to prevent bundling them * fix: correct useSprings implementation in SplitText component * fix: circular dependencies in DataTable * fix: add remaining peer dependencies and match actual versions previously used in `client/package.json` * fix: correct frontend:ci script to include client package build * chore: enhance unused package detection for @librechat/client and improve dependency extraction * fix: add missing peer dependency for @radix-ui/react-collapsible * chore: include "packages/client" in unused i18next keys detection * test: update AgentFooter tests to use document.querySelector for spinner checks test: mock window.matchMedia in setupTests.js for consistent test environment * feat: add react-hook-form dependency and update FormInput component to use its types * chore: linting * refactor: remove unused defaultSelectedValues prop from MCPSelect and MultiSelect components * chore: linting * feat: update GitHub Actions workflow to publish @librechat/client * chore: update GitHub Actions workflow to install and build data-provider and client dependencies * chore: add missing @testing-library/react dependency to client package * chore: update tsconfig.json to exclude additional test files * chore: fix build issues, resolve latest LC changes * chore: move MCP components outside of `~/components/ui` * feat: implement dynamic theme system with environment variable support and Tailwind CSS integration * chore: remove unnecessary logging of sttExternal and ttsExternal in Speech component * chore: squashed cleanup commits chore: move @tanstack/react-virtual to dependencies and remove recoil from package.json chore: move dependencies to peerDependencies in package.json feat: update package.json and rollup.config.js to include jotai and enhance bundling configuration feat: update package.json and rollup.config.js to include jotai and enhance bundling configuration refactor: reorganize exports in index.ts for improved clarity refactor: remove unused types and interfaces from common files refactor: update peer dependencies and improve component typings - Removed duplicate peer dependencies from package.json and organized them. - Updated rollup.config.js to disable TypeScript checking during the build process. - Modified AnimatedTabs component to use React.ReactNode for label and content types, and added TypeScript workarounds for compatibility. - Enhanced Label and Separator components to accept an optional className prop and improved prop spreading. - Updated Slider component to include an optional className prop and refined prop handling for better type safety. refactor: clean up client workflow and update package dependencies refactor: update package dependencies and improve PostCSS and Rollup configurations chore: bump version to 0.1.2 in package.json chore: bump client version to 0.1.2 in package-lock.json chore: bump client version to 0.1.3 and update dependencies chore: bump client version to 0.1.4 and update @react-spring dependencies chore: update package version to 0.1.5 and adjust peer dependencies - Bump version in package.json from 0.1.4 to 0.1.5. - Update peer dependency for @tanstack/react-query to allow version 5.0.0. - Add @tanstack/react-table and @tanstack/react-virtual as dependencies. - Update various dependencies to their latest compatible versions. - Simplify postcss.config.js by removing unnecessary options. - Clean up rollup.config.js by removing ignored PostCSS warnings. - Update CheckboxButton component to cast icon as React JSX element. - Adjust Combobox component's class names for better styling. - Change DropdownPopup component to use React's namespace import. - Modify InputOTP component to use 'any' type for OTPInputContext. - Ensure displayLabel and value in ModelParameters are converted to strings. - Update MultiSearch component's placeholder to ensure it's a string. - Cast selectIcon in MultiSelect as React JSX element for consistency. - Update OGDialogTemplate to cast selectText as React JSX element. - Initialize animationRef in PixelCard with undefined for clarity. - Add TypeScript ignore comments in Select and SelectDropDown components for Radix UI type conflicts. - Ensure title in SelectDropDown is a string and adjust rendering of options. - Update useLocalize hook to cast options as any for compatibility. refactor: code structure; chore: translations cleanup chore: remove unused imports and clean up code in NewChat component refactor: enhance Menu component to support custom render functions for menu items style: update itemClassName in ToolsDropdown for improved UI consistency fix: merge conflicts chore: update @radix-ui/react-accordion to version 1.2.11 * refactor: remove unnecessary TypeScript type assertions in AnimatedTabs, Label, Separator, and Slider components * feat: enhance theme system with localStorage persistence and new theme atoms * chore: bump version of @librechat/client to 0.1.7 * chore: fix ci/cd warnings/errors related to linting and unused localization keys * chore: update dependencies for class-variance-authority, clsx, and match-sorter * chore: bump @librechat/client to v0.1.8 * feat: add utility colors for theme customization and remove unused tailwindConfig * v0.1.9 --------- Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
parent
97e1cdd224
commit
79197454f8
569 changed files with 7010 additions and 1848 deletions
92
packages/client/package.json
Normal file
92
packages/client/package.json
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{
|
||||
"name": "@librechat/client",
|
||||
"version": "0.1.9",
|
||||
"description": "React components for LibreChat",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.es.js",
|
||||
"types": "dist/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.es.js",
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/types/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"build": "npm run clean && rollup -c --bundleConfigAsCjs",
|
||||
"build:watch": "rollup -c -w --bundleConfigAsCjs",
|
||||
"dev": "rollup -c -w --bundleConfigAsCjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^4.28.0 || ^5.0.0",
|
||||
"i18next": "^24.2.2 || ^25.3.2",
|
||||
"jotai": "^2.12.5",
|
||||
"react": "^18.2.0 || ^19.1.0",
|
||||
"react-dom": "^18.2.0 || ^19.1.0",
|
||||
"react-i18next": "^15.4.0 || ^15.6.0",
|
||||
"@tanstack/react-table": "^8.11.7",
|
||||
"@tanstack/react-virtual": "^3.0.0",
|
||||
"@ariakit/react": "^0.4.16",
|
||||
"@ariakit/react-core": "^0.4.17",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.0.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.0.5",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.0.0",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.0.3",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@react-spring/web": "^10.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.6",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"match-sorter": "^8.1.0",
|
||||
"rc-input-number": "^7.4.2",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
"tailwind-merge": "^1.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@rollup/plugin-commonjs": "^25.0.2",
|
||||
"@rollup/plugin-node-resolve": "^15.0.0",
|
||||
"@rollup/plugin-replace": "^5.0.5",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/react": "^18.2.11",
|
||||
"@types/react-dom": "^18.2.4",
|
||||
"concat-with-sourcemaps": "^1.1.0",
|
||||
"i18next": "^24.2.3",
|
||||
"jotai": "^2.12.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^15.4.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"rollup": "^4.0.0",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||
"rollup-plugin-postcss": "^4.0.2",
|
||||
"rollup-plugin-typescript2": "^0.35.0",
|
||||
"tailwindcss-radix": "^2.8.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
84
packages/client/rollup.config.js
Normal file
84
packages/client/rollup.config.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// ESM bundler config for React components
|
||||
import { fileURLToPath } from 'url';
|
||||
import alias from '@rollup/plugin-alias';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
import postcss from 'rollup-plugin-postcss';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import typescript from 'rollup-plugin-typescript2';
|
||||
import { dirname, resolve as pathResolve } from 'path';
|
||||
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
|
||||
import pkg from './package.json';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const plugins = [
|
||||
peerDepsExternal(),
|
||||
alias({
|
||||
entries: [{ find: '~', replacement: pathResolve(__dirname, 'src') }],
|
||||
}),
|
||||
resolve({
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
browser: true,
|
||||
preferBuiltins: false,
|
||||
}),
|
||||
replace({
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
|
||||
preventAssignment: true,
|
||||
}),
|
||||
commonjs(),
|
||||
postcss({
|
||||
extract: false,
|
||||
inject: true,
|
||||
minimize: process.env.NODE_ENV === 'production',
|
||||
modules: false,
|
||||
config: {
|
||||
path: './postcss.config.js',
|
||||
},
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: './tsconfig.json',
|
||||
useTsconfigDeclarationDir: true,
|
||||
clean: true,
|
||||
check: false,
|
||||
}),
|
||||
terser({
|
||||
compress: {
|
||||
directives: false,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export default {
|
||||
input: 'src/index.ts',
|
||||
output: [
|
||||
{
|
||||
file: pkg.main,
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
exports: 'named',
|
||||
},
|
||||
{
|
||||
file: pkg.module,
|
||||
format: 'esm',
|
||||
sourcemap: true,
|
||||
exports: 'named',
|
||||
},
|
||||
],
|
||||
external: [
|
||||
...Object.keys(pkg.peerDependencies || {}),
|
||||
'react/jsx-runtime',
|
||||
'react/jsx-dev-runtime',
|
||||
],
|
||||
preserveSymlinks: true,
|
||||
plugins,
|
||||
onwarn(warning, warn) {
|
||||
// Ignore "use client" directive warnings
|
||||
if (warning.code === 'MODULE_LEVEL_DIRECTIVE') {
|
||||
return;
|
||||
}
|
||||
warn(warning);
|
||||
},
|
||||
};
|
||||
21
packages/client/src/Providers/ToastContext.tsx
Normal file
21
packages/client/src/Providers/ToastContext.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { createContext, useContext, ReactNode } from 'react';
|
||||
import type { TShowToast } from '~/common';
|
||||
import useToast from '~/hooks/useToast';
|
||||
|
||||
type ToastContextType = {
|
||||
showToast: ({ message, severity, showIcon, duration }: TShowToast) => void;
|
||||
};
|
||||
|
||||
export const ToastContext = createContext<ToastContextType>({
|
||||
showToast: () => ({}),
|
||||
});
|
||||
|
||||
export function useToastContext() {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
|
||||
export default function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const { showToast } = useToast();
|
||||
|
||||
return <ToastContext.Provider value={{ showToast }}>{children}</ToastContext.Provider>;
|
||||
}
|
||||
2
packages/client/src/Providers/index.ts
Normal file
2
packages/client/src/Providers/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ToastProvider } from './ToastContext';
|
||||
export * from './ToastContext';
|
||||
11
packages/client/src/common/index.ts
Normal file
11
packages/client/src/common/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export type {
|
||||
TShowToast,
|
||||
Option,
|
||||
OptionWithIcon,
|
||||
DropdownValueSetter,
|
||||
MentionOption,
|
||||
} from './types';
|
||||
|
||||
export { NotificationSeverity } from './types';
|
||||
|
||||
export type { MenuItemProps } from './menus';
|
||||
24
packages/client/src/common/menus.ts
Normal file
24
packages/client/src/common/menus.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export type RenderProp<
|
||||
P = React.HTMLAttributes<any> & {
|
||||
ref?: React.Ref<any>;
|
||||
},
|
||||
> = (props: P) => React.ReactNode;
|
||||
|
||||
export interface MenuItemProps {
|
||||
id?: string;
|
||||
label?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement | HTMLDivElement>) => void;
|
||||
icon?: React.ReactNode;
|
||||
kbd?: string;
|
||||
show?: boolean;
|
||||
disabled?: boolean;
|
||||
separate?: boolean;
|
||||
hideOnClick?: boolean;
|
||||
dialog?: React.ReactElement;
|
||||
ref?: React.Ref<any>;
|
||||
render?:
|
||||
| RenderProp<React.HTMLAttributes<any> & { ref?: React.Ref<any> | undefined }>
|
||||
| React.ReactElement<any, string | React.JSXElementConstructor<any>>
|
||||
| undefined;
|
||||
}
|
||||
33
packages/client/src/common/types.ts
Normal file
33
packages/client/src/common/types.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export enum NotificationSeverity {
|
||||
INFO = 'info',
|
||||
SUCCESS = 'success',
|
||||
WARNING = 'warning',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export type TShowToast = {
|
||||
message: string;
|
||||
severity?: NotificationSeverity;
|
||||
showIcon?: boolean;
|
||||
duration?: number;
|
||||
status?: 'error' | 'success' | 'warning' | 'info';
|
||||
};
|
||||
|
||||
export type Option = Record<string, unknown> & {
|
||||
label?: string;
|
||||
value: string | number | null;
|
||||
};
|
||||
|
||||
export type OptionWithIcon = Option & { icon?: React.ReactNode };
|
||||
export type DropdownValueSetter = (value: string | Option | OptionWithIcon) => void;
|
||||
export type MentionOption = OptionWithIcon & {
|
||||
type: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export interface SelectedValues {
|
||||
endpoint: string | null;
|
||||
model: string | null;
|
||||
modelSpec: string | null;
|
||||
}
|
||||
50
packages/client/src/components/Accordion.tsx
Normal file
50
packages/client/src/components/Accordion.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import * as React from 'react';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDownIcon } from '@radix-ui/react-icons';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<AccordionPrimitive.Item ref={ref} className={cn('border-b', className)} {...props} />
|
||||
));
|
||||
AccordionItem.displayName = 'AccordionItem';
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className = '', children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className = '', children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
131
packages/client/src/components/AlertDialog.tsx
Normal file
131
packages/client/src/components/AlertDialog.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
type AlertPortalProps = AlertDialogPrimitive.AlertDialogPortalProps & { className?: string };
|
||||
|
||||
const AlertDialogPortal = ({ className = '', children, ...props }: AlertPortalProps) => (
|
||||
<AlertDialogPrimitive.Portal className={cn(className)} {...(props as AlertPortalProps)}>
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
|
||||
{children}
|
||||
</div>
|
||||
</AlertDialogPrimitive.Portal>
|
||||
);
|
||||
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-gray-500/90 transition-opacity animate-in fade-in dark:bg-gray-800/90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed z-50 grid w-full max-w-lg scale-100 gap-4 bg-white p-6 opacity-100 animate-in fade-in-90 slide-in-from-bottom-10 sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 md:w-full',
|
||||
'dark:bg-gray-900',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className = '', ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({ className = '', ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-gray-900', 'dark:text-gray-50', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-gray-500', 'dark:text-gray-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-gray-900 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-100 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 inline-flex h-10 items-center justify-center rounded-md border border-gray-200 bg-transparent px-4 py-2 text-sm font-semibold text-gray-900 transition-colors hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900 sm:mt-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
79
packages/client/src/components/AnimatedSearchInput.tsx
Normal file
79
packages/client/src/components/AnimatedSearchInput.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import React from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const AnimatedSearchInput = ({
|
||||
value,
|
||||
onChange,
|
||||
isSearching: searching,
|
||||
placeholder,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
isSearching?: boolean;
|
||||
placeholder: string;
|
||||
}) => {
|
||||
const isSearching = searching === true;
|
||||
const hasValue = value != null && value.length > 0;
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="relative rounded-lg transition-all duration-500 ease-in-out">
|
||||
<div className="relative">
|
||||
{/* Icon on the left */}
|
||||
<div className="absolute left-3 top-1/2 z-50 -translate-y-1/2">
|
||||
<Search
|
||||
className={cn(
|
||||
`h-4 w-4 transition-all duration-500 ease-in-out`,
|
||||
isSearching && hasValue ? 'text-blue-400' : 'text-gray-400',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Input field */}
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={`peer relative z-20 w-full rounded-lg bg-surface-secondary px-10 py-2 outline-none ring-0 backdrop-blur-sm transition-all duration-500 ease-in-out placeholder:text-gray-400 focus:outline-none focus:ring-0`}
|
||||
/>
|
||||
|
||||
{/* Gradient overlay */}
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-0 z-20 rounded-lg bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-blue-500/20 transition-all duration-500 ease-in-out ${isSearching && hasValue ? 'opacity-100 blur-sm' : 'opacity-0 blur-none'} `}
|
||||
/>
|
||||
|
||||
{/* Animated loading indicator */}
|
||||
<div
|
||||
className={`absolute right-3 top-1/2 z-20 -translate-y-1/2 transition-all duration-500 ease-in-out ${isSearching && hasValue ? 'scale-100 opacity-100' : 'scale-0 opacity-0'} `}
|
||||
>
|
||||
<div className="relative h-2 w-2">
|
||||
<div className="absolute inset-0 animate-ping rounded-full bg-blue-500/60" />
|
||||
<div className="absolute inset-0 rounded-full bg-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outer glow effect */}
|
||||
<div
|
||||
className={`absolute -inset-8 -z-10 transition-all duration-700 ease-in-out ${isSearching && hasValue ? 'scale-105 opacity-100' : 'scale-100 opacity-0'} `}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<div
|
||||
className={`bg-gradient-radial absolute inset-0 from-blue-500/10 to-transparent transition-opacity duration-700 ease-in-out ${isSearching && hasValue ? 'animate-pulse-slow opacity-100' : 'opacity-0'} `}
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-r from-purple-500/5 via-blue-500/5 to-purple-500/5 blur-xl transition-all duration-700 ease-in-out ${isSearching && hasValue ? 'animate-gradient-x opacity-100' : 'opacity-0'} `}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`absolute inset-0 -z-20 scale-100 bg-gradient-to-r from-blue-500/10 via-purple-500/10 to-blue-500/10 opacity-0 blur-xl transition-all duration-500 ease-in-out peer-focus:scale-105 peer-focus:opacity-100`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedSearchInput;
|
||||
57
packages/client/src/components/AnimatedTabs.css
Normal file
57
packages/client/src/components/AnimatedTabs.css
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/* AnimatedTabs.css */
|
||||
.animated-tab-panel {
|
||||
transition-property: opacity, translate;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 300ms;
|
||||
animation-duration: 300ms;
|
||||
}
|
||||
|
||||
/* Sliding underline animation for tabs */
|
||||
.animated-tab-list {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.animated-tab-list::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background-color: currentColor; /* Inherit color from active tab */
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
left: var(--tab-left, 0);
|
||||
width: var(--tab-width, 0);
|
||||
}
|
||||
|
||||
.animated-tab {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.animated-tab[data-state='active'] {
|
||||
border-bottom-color: transparent !important;
|
||||
}
|
||||
|
||||
.animated-tab-panel[data-enter] {
|
||||
opacity: 1 !important;
|
||||
translate: 0 !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animated-tab-panel {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.animated-tab-panel:not([data-open]) {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.animated-panels:has(> [data-was-open]) > .animated-tab-panel {
|
||||
opacity: 0;
|
||||
translate: -100%;
|
||||
}
|
||||
|
||||
.animated-panels [data-was-open] ~ .animated-tab-panel,
|
||||
.animated-panels [data-open] ~ .animated-tab-panel {
|
||||
translate: 100%;
|
||||
}
|
||||
162
packages/client/src/components/AnimatedTabs.tsx
Normal file
162
packages/client/src/components/AnimatedTabs.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { forwardRef, useEffect, useRef } from 'react';
|
||||
import type { ElementRef } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
import './AnimatedTabs.css';
|
||||
|
||||
export interface TabItem {
|
||||
id?: string;
|
||||
label: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface AnimatedTabsProps {
|
||||
tabs: TabItem[];
|
||||
className?: string;
|
||||
tabListClassName?: string;
|
||||
tabClassName?: string;
|
||||
tabPanelClassName?: string;
|
||||
tabListProps?: Ariakit.TabListProps;
|
||||
containerClassName?: string;
|
||||
defaultSelectedId?: string;
|
||||
}
|
||||
|
||||
function usePrevious<T>(value: T) {
|
||||
const ref = useRef<T | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
const Tab = forwardRef<ElementRef<typeof Ariakit.Tab>, Ariakit.TabProps>(function Tab(props, ref) {
|
||||
const tabRef = useRef<HTMLButtonElement | null>(null);
|
||||
useEffect(() => {
|
||||
const tabElement = tabRef.current;
|
||||
if (!tabElement) return;
|
||||
|
||||
const updateState = () => {
|
||||
const isSelected = tabElement.getAttribute('aria-selected') === 'true';
|
||||
tabElement.setAttribute('data-state', isSelected ? 'active' : 'inactive');
|
||||
};
|
||||
|
||||
updateState();
|
||||
|
||||
const observer = new MutationObserver(updateState);
|
||||
observer.observe(tabElement, { attributes: true, attributeFilter: ['aria-selected'] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Ariakit.Tab
|
||||
ref={(node) => {
|
||||
// Forward the ref to both our local ref and the provided ref
|
||||
tabRef.current = node;
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
}}
|
||||
{...props}
|
||||
className={`animated-tab aria-selected:text-token-text-primary flex select-none items-center justify-center gap-2 whitespace-nowrap border-none text-sm font-medium outline-none transition-colors aria-disabled:opacity-50 ${props.className || ''}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const TabPanel = forwardRef<ElementRef<typeof Ariakit.TabPanel>, Ariakit.TabPanelProps>(
|
||||
function TabPanel(props, ref) {
|
||||
const tab = Ariakit.useTabContext();
|
||||
const previousTabId = usePrevious(Ariakit.useStoreState(tab, 'selectedId'));
|
||||
const wasOpen = props.tabId && previousTabId === props.tabId;
|
||||
|
||||
return (
|
||||
<Ariakit.TabPanel
|
||||
ref={ref}
|
||||
{...props}
|
||||
data-was-open={wasOpen || undefined}
|
||||
className={`animated-tab-panel max-w-full ${props.className || ''}`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export function AnimatedTabs({
|
||||
tabs,
|
||||
className = '',
|
||||
tabListClassName = '',
|
||||
tabClassName = '',
|
||||
tabPanelClassName = '',
|
||||
containerClassName = '',
|
||||
tabListProps = {},
|
||||
defaultSelectedId,
|
||||
}: AnimatedTabsProps) {
|
||||
const tabIds = tabs.map((tab, index) => tab.id || `tab-${index}`);
|
||||
const firstTabId = defaultSelectedId || tabIds[0];
|
||||
const tabListRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const tabList = tabListRef.current;
|
||||
if (!tabList) return;
|
||||
|
||||
// Function to update the underline position
|
||||
const updateUnderline = () => {
|
||||
const activeTab = tabList.querySelector('[data-state="active"]') as HTMLElement;
|
||||
if (!activeTab) return;
|
||||
|
||||
tabList.style.setProperty('--tab-left', `${activeTab.offsetLeft}px`);
|
||||
tabList.style.setProperty('--tab-width', `${activeTab.offsetWidth}px`);
|
||||
};
|
||||
|
||||
updateUnderline();
|
||||
|
||||
const observer = new MutationObserver(updateUnderline);
|
||||
observer.observe(tabList, { attributes: true, subtree: true, attributeFilter: ['data-state'] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [tabs]);
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<Ariakit.TabProvider defaultSelectedId={firstTabId}>
|
||||
<Ariakit.TabList
|
||||
ref={tabListRef}
|
||||
aria-label="Tabs"
|
||||
className={`animated-tab-list flex py-1 ${tabListClassName}`}
|
||||
{...tabListProps}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<Tab
|
||||
key={tabIds[index]}
|
||||
id={tabIds[index]}
|
||||
disabled={tab.disabled}
|
||||
className={tabClassName}
|
||||
data-state={tabIds[index] === firstTabId ? 'active' : 'inactive'}
|
||||
>
|
||||
{/* TypeScript workaround for React i18next children type compatibility */}
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</Ariakit.TabList>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'animated-panels relative flex w-full flex-col items-center overflow-hidden p-0',
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<TabPanel
|
||||
key={`panel-${tabIds[index]}`}
|
||||
id={`panel-${tabIds[index]}`}
|
||||
tabId={tabIds[index]}
|
||||
className={tabPanelClassName}
|
||||
>
|
||||
{/* TypeScript workaround for React i18next children type compatibility */}
|
||||
{tab.content}
|
||||
</TabPanel>
|
||||
))}
|
||||
</div>
|
||||
</Ariakit.TabProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
packages/client/src/components/Badge.tsx
Normal file
100
packages/client/src/components/Badge.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import type React from 'react';
|
||||
import { X, Plus } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface BadgeProps
|
||||
extends Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'
|
||||
> {
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
id?: string;
|
||||
isActive?: boolean;
|
||||
isEditing?: boolean;
|
||||
isDragging?: boolean;
|
||||
isAvailable: boolean;
|
||||
isInChat?: boolean;
|
||||
onBadgeAction?: () => void;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export default function Badge({
|
||||
icon: Icon,
|
||||
label,
|
||||
id,
|
||||
isActive = false,
|
||||
isEditing = false,
|
||||
isDragging = false,
|
||||
isAvailable = true,
|
||||
isInChat = false,
|
||||
onBadgeAction,
|
||||
onToggle,
|
||||
className,
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
const isMoveable = isEditing && isAvailable;
|
||||
const isDisabled = id === '1' && isInChat;
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditing && onToggle) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'group relative inline-flex items-center gap-1.5 rounded-full px-4 py-1.5',
|
||||
'border border-border-medium text-sm font-medium transition-shadow md:w-full',
|
||||
'size-9 p-2 md:p-3',
|
||||
isActive
|
||||
? 'bg-surface-active shadow-md'
|
||||
: 'bg-surface-chat shadow-sm hover:bg-surface-hover hover:shadow-md',
|
||||
'active:scale-95 active:shadow-inner',
|
||||
isMoveable && 'cursor-move',
|
||||
isDisabled && 'cursor-not-allowed opacity-50 hover:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
animate={{
|
||||
scale: isDragging ? 1.1 : 1,
|
||||
boxShadow: isDragging ? '0 10px 25px rgba(0,0,0,0.1)' : undefined,
|
||||
}}
|
||||
whileTap={{ scale: isDragging ? 1.1 : isDisabled ? 1 : 0.97 }}
|
||||
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
|
||||
{...(props as React.ComponentProps<typeof motion.button>)}
|
||||
>
|
||||
{Icon && <Icon className={cn('relative h-5 w-5 md:h-4 md:w-4', !label && 'mx-auto')} />}
|
||||
<span className="relative hidden md:inline">{label}</span>
|
||||
|
||||
{isEditing && !isDragging && (
|
||||
<motion.button
|
||||
className="absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full bg-surface-secondary-alt text-text-primary shadow-sm md:h-5 md:w-5"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onBadgeAction?.();
|
||||
}}
|
||||
>
|
||||
{isAvailable ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
100
packages/client/src/components/Breadcrumb.tsx
Normal file
100
packages/client/src/components/Breadcrumb.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<'nav'> & {
|
||||
separator?: React.ReactNode;
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||
Breadcrumb.displayName = 'Breadcrumb';
|
||||
|
||||
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<'ol'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbList.displayName = 'BreadcrumbList';
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<'li'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn('inline-flex items-center gap-1.5', className)} {...props} />
|
||||
),
|
||||
);
|
||||
BreadcrumbItem.displayName = 'BreadcrumbItem';
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<'a'> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn('transition-colors hover:text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = 'BreadcrumbLink';
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<'span'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('font-normal text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbPage.displayName = 'BreadcrumbPage';
|
||||
|
||||
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<'li'>) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
|
||||
|
||||
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
52
packages/client/src/components/Button.tsx
Normal file
52
packages/client/src/components/Button.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-surface-destructive text-destructive-foreground hover:bg-surface-destructive-hover',
|
||||
outline:
|
||||
'text-text-primary border border-border-light bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-surface-hover hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
// hardcoded text color because of WCAG contrast issues (text-white)
|
||||
submit: 'bg-surface-submit text-white hover:bg-surface-submit-hover',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-lg px-3',
|
||||
lg: 'h-11 rounded-lg px-8',
|
||||
icon: 'size-10',
|
||||
},
|
||||
},
|
||||
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 };
|
||||
25
packages/client/src/components/Checkbox.tsx
Normal file
25
packages/client/src/components/Checkbox.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center')}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
76
packages/client/src/components/CheckboxButton.tsx
Normal file
76
packages/client/src/components/CheckboxButton.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import * as React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Checkbox, useStoreState, useCheckboxStore } from '@ariakit/react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const CheckboxButton = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
className?: string;
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
isCheckedClassName?: string;
|
||||
setValue?: (values: {
|
||||
e?: React.ChangeEvent<HTMLInputElement>;
|
||||
value: boolean | string;
|
||||
}) => void;
|
||||
}
|
||||
>(({ icon, label, setValue, className, checked, defaultChecked, isCheckedClassName }, ref) => {
|
||||
const checkbox = useCheckboxStore();
|
||||
const isChecked = useStoreState(checkbox, (state) => state?.value);
|
||||
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
if (typeof isChecked !== 'boolean') {
|
||||
return;
|
||||
}
|
||||
setValue?.({ e, value: !isChecked });
|
||||
};
|
||||
|
||||
// Sync with controlled checked prop
|
||||
useEffect(() => {
|
||||
if (checked !== undefined) {
|
||||
checkbox.setValue(checked);
|
||||
}
|
||||
}, [checked, checkbox]);
|
||||
|
||||
// Set initial value from defaultChecked
|
||||
useEffect(() => {
|
||||
if (defaultChecked !== undefined && checked === undefined) {
|
||||
checkbox.setValue(defaultChecked);
|
||||
}
|
||||
}, [defaultChecked, checked, checkbox]);
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
ref={ref}
|
||||
store={checkbox}
|
||||
onChange={onChange}
|
||||
className={cn(
|
||||
// Base styling from MultiSelect's selectClassName
|
||||
'group relative inline-flex items-center justify-center gap-1.5',
|
||||
'rounded-full border border-border-medium text-sm font-medium',
|
||||
'size-9 p-2 transition-all md:w-full md:p-3',
|
||||
'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner',
|
||||
|
||||
// Checked state styling
|
||||
isChecked && isCheckedClassName && isCheckedClassName,
|
||||
|
||||
// Additional custom classes
|
||||
className,
|
||||
)}
|
||||
render={<button type="button" aria-label={label} />}
|
||||
>
|
||||
{/* Icon if provided */}
|
||||
{icon && <span className="icon-md text-text-primary">{icon as React.JSX.Element}</span>}
|
||||
|
||||
{/* Show the label on larger screens */}
|
||||
<span className="hidden truncate md:block">{label}</span>
|
||||
</Checkbox>
|
||||
);
|
||||
});
|
||||
|
||||
CheckboxButton.displayName = 'CheckboxButton';
|
||||
|
||||
export default CheckboxButton;
|
||||
9
packages/client/src/components/Collapsible.tsx
Normal file
9
packages/client/src/components/Collapsible.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
176
packages/client/src/components/Combobox.tsx
Normal file
176
packages/client/src/components/Combobox.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { startTransition } from 'react';
|
||||
import { Search as SearchIcon } from 'lucide-react';
|
||||
import * as RadixSelect from '@radix-ui/react-select';
|
||||
import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxProvider,
|
||||
ComboboxCancel,
|
||||
} from '@ariakit/react';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
import { SelectTrigger, SelectValue, SelectScrollDownButton } from './Select';
|
||||
import { useCombobox } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function ComboboxComponent({
|
||||
selectedValue,
|
||||
displayValue,
|
||||
items,
|
||||
setValue,
|
||||
ariaLabel,
|
||||
searchPlaceholder,
|
||||
selectPlaceholder,
|
||||
isCollapsed,
|
||||
SelectIcon,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
displayValue?: string;
|
||||
selectedValue: string;
|
||||
searchPlaceholder?: string;
|
||||
selectPlaceholder?: string;
|
||||
items: OptionWithIcon[] | string[];
|
||||
setValue: (value: string) => void;
|
||||
isCollapsed: boolean;
|
||||
SelectIcon?: React.ReactNode;
|
||||
}) {
|
||||
const options: OptionWithIcon[] = (items ?? []).map((option: string | OptionWithIcon) => {
|
||||
if (typeof option === 'string') {
|
||||
return { label: option, value: option };
|
||||
}
|
||||
return option;
|
||||
});
|
||||
|
||||
const { open, setOpen, setSearchValue, matches } = useCombobox({
|
||||
value: selectedValue,
|
||||
options,
|
||||
});
|
||||
|
||||
return (
|
||||
<RadixSelect.Root
|
||||
value={selectedValue}
|
||||
onValueChange={setValue}
|
||||
open={open}
|
||||
/** Hacky fix for radix-ui Android issue: https://github.com/radix-ui/primitives/issues/1658 */
|
||||
onOpenChange={() => {
|
||||
if (open === true) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
setOpen(!open);
|
||||
}, 75);
|
||||
}}
|
||||
>
|
||||
<ComboboxProvider
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
resetValueOnHide
|
||||
includesBaseElement={false}
|
||||
setValue={(value) => {
|
||||
startTransition(() => {
|
||||
setSearchValue(value);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'flex items-center gap-2 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0',
|
||||
isCollapsed
|
||||
? 'flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden'
|
||||
: '',
|
||||
'bg-white text-black hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-gray-500 dark:bg-gray-850 dark:text-white',
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder={selectPlaceholder}>
|
||||
<div className="assistant-item flex items-center justify-center overflow-hidden rounded-full">
|
||||
{SelectIcon ? SelectIcon : <ChevronDownIcon />}
|
||||
</div>
|
||||
<span
|
||||
className={cn('ml-2', isCollapsed ? 'hidden' : '')}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
{selectedValue
|
||||
? (displayValue ?? selectedValue)
|
||||
: selectPlaceholder && selectPlaceholder}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<RadixSelect.Portal>
|
||||
<RadixSelect.Content
|
||||
role="dialog"
|
||||
aria-label={ariaLabel + 's'}
|
||||
position="popper"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground relative z-50 max-h-[52vh] min-w-[8rem] overflow-hidden rounded-md border border-gray-200 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 dark:border-gray-600',
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
'bg-white dark:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
<RadixSelect.Viewport className="mb-5 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
|
||||
<div className="group sticky left-0 top-0 z-10 flex h-12 items-center gap-2 bg-white px-3 py-2 text-black duration-300 dark:bg-gray-700 dark:text-white">
|
||||
<SearchIcon className="h-4 w-4 text-gray-500 transition-colors duration-300 dark:group-focus-within:text-gray-300 dark:group-hover:text-gray-300" />
|
||||
<Combobox
|
||||
autoSelect
|
||||
placeholder={searchPlaceholder}
|
||||
className="flex-1 rounded-md border-none bg-transparent px-2.5 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-700/10 dark:focus:ring-gray-200/10"
|
||||
// Ariakit's Combobox manually triggers a blur event on virtually
|
||||
// blurred items, making them work as if they had actual DOM
|
||||
// focus. These blur events might happen after the corresponding
|
||||
// focus events in the capture phase, leading Radix Select to
|
||||
// close the popover. This happens because Radix Select relies on
|
||||
// the order of these captured events to discern if the focus was
|
||||
// outside the element. Since we don't have access to the
|
||||
// onInteractOutside prop in the Radix SelectContent component to
|
||||
// stop this behavior, we can turn off Ariakit's behavior here.
|
||||
onBlurCapture={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<ComboboxCancel
|
||||
hideWhenEmpty={true}
|
||||
className="relative flex h-5 w-5 items-center justify-end text-gray-500 transition-colors duration-300 dark:group-focus-within:text-gray-300 dark:group-hover:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<ComboboxList className="overflow-y-auto p-1 py-2">
|
||||
{matches.map(({ label, value, icon }) => (
|
||||
<RadixSelect.Item key={value} value={`${value ?? ''}`} asChild>
|
||||
<ComboboxItem
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'rounded-lg hover:bg-gray-100/50 hover:bg-gray-50 dark:text-white dark:hover:bg-gray-600',
|
||||
)}
|
||||
/** Hacky fix for radix-ui Android issue: https://github.com/radix-ui/primitives/issues/1658 */
|
||||
onTouchEnd={() => {
|
||||
setValue(`${value ?? ''}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<RadixSelect.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</RadixSelect.ItemIndicator>
|
||||
</span>
|
||||
<RadixSelect.ItemText>
|
||||
<div className="flex items-center justify-center gap-3 dark:text-white [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-foreground">
|
||||
<div className="assistant-item overflow-hidden rounded-full">
|
||||
{icon && icon}
|
||||
</div>
|
||||
{label}
|
||||
</div>
|
||||
</RadixSelect.ItemText>
|
||||
</ComboboxItem>
|
||||
</RadixSelect.Item>
|
||||
))}
|
||||
</ComboboxList>
|
||||
</RadixSelect.Viewport>
|
||||
<SelectScrollDownButton className="absolute bottom-0 left-0 right-0" />
|
||||
</RadixSelect.Content>
|
||||
</RadixSelect.Portal>
|
||||
</ComboboxProvider>
|
||||
</RadixSelect.Root>
|
||||
);
|
||||
}
|
||||
182
packages/client/src/components/ControlCombobox.tsx
Normal file
182
packages/client/src/components/ControlCombobox.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import { Search, ChevronDown } from 'lucide-react';
|
||||
import { useMemo, useState, useRef, memo, useEffect } from 'react';
|
||||
import { SelectRenderer } from '@ariakit/react-core/select/select-renderer';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface ControlComboboxProps {
|
||||
selectedValue: string;
|
||||
displayValue?: string;
|
||||
items: OptionWithIcon[];
|
||||
setValue: (value: string) => void;
|
||||
ariaLabel: string;
|
||||
searchPlaceholder?: string;
|
||||
selectPlaceholder?: string;
|
||||
isCollapsed: boolean;
|
||||
SelectIcon?: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
iconClassName?: string;
|
||||
showCarat?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
iconSide?: 'left' | 'right';
|
||||
selectId?: string;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
|
||||
function ControlCombobox({
|
||||
selectedValue,
|
||||
displayValue,
|
||||
items,
|
||||
setValue,
|
||||
ariaLabel,
|
||||
searchPlaceholder,
|
||||
selectPlaceholder,
|
||||
containerClassName,
|
||||
isCollapsed,
|
||||
SelectIcon,
|
||||
showCarat,
|
||||
className,
|
||||
disabled,
|
||||
iconClassName,
|
||||
iconSide = 'left',
|
||||
selectId,
|
||||
}: ControlComboboxProps) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [buttonWidth, setButtonWidth] = useState<number | null>(null);
|
||||
|
||||
const getItem = (option: OptionWithIcon) => ({
|
||||
id: `item-${option.value}`,
|
||||
value: option.value as string | undefined,
|
||||
label: option.label,
|
||||
icon: option.icon,
|
||||
});
|
||||
|
||||
const combobox = Ariakit.useComboboxStore({
|
||||
defaultItems: items.map(getItem),
|
||||
resetValueOnHide: true,
|
||||
value: searchValue,
|
||||
setValue: setSearchValue,
|
||||
});
|
||||
|
||||
const select = Ariakit.useSelectStore({
|
||||
combobox,
|
||||
defaultItems: items.map(getItem),
|
||||
value: selectedValue,
|
||||
setValue,
|
||||
});
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const filteredItems = matchSorter(items, searchValue, {
|
||||
keys: ['value', 'label'],
|
||||
baseSort: (a, b) => (a.index < b.index ? -1 : 1),
|
||||
});
|
||||
return filteredItems.map(getItem);
|
||||
}, [searchValue, items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (buttonRef.current && !isCollapsed) {
|
||||
setButtonWidth(buttonRef.current.offsetWidth);
|
||||
}
|
||||
}, [isCollapsed]);
|
||||
|
||||
const selectIconClassName = cn(
|
||||
'flex h-5 w-5 items-center justify-center overflow-hidden rounded-full',
|
||||
iconClassName,
|
||||
);
|
||||
const optionIconClassName = cn(
|
||||
'mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full',
|
||||
iconClassName,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full items-center justify-center px-1', containerClassName)}>
|
||||
<Ariakit.SelectLabel store={select} className="sr-only">
|
||||
{ariaLabel}
|
||||
</Ariakit.SelectLabel>
|
||||
<Ariakit.Select
|
||||
ref={buttonRef}
|
||||
store={select}
|
||||
id={selectId}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
|
||||
'text-text-primary hover:bg-surface-tertiary',
|
||||
'border border-border-light',
|
||||
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-xl px-3 py-2 text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{SelectIcon != null && iconSide === 'left' && (
|
||||
<div className={selectIconClassName}>{SelectIcon}</div>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-grow truncate text-left">
|
||||
{displayValue != null
|
||||
? displayValue || selectPlaceholder
|
||||
: selectedValue || selectPlaceholder}
|
||||
</span>
|
||||
{SelectIcon != null && iconSide === 'right' && (
|
||||
<div className={selectIconClassName}>{SelectIcon}</div>
|
||||
)}
|
||||
{showCarat && <ChevronDown className="h-4 w-4 text-text-secondary" />}
|
||||
</>
|
||||
)}
|
||||
</Ariakit.Select>
|
||||
<Ariakit.SelectPopover
|
||||
store={select}
|
||||
gutter={4}
|
||||
portal
|
||||
className={cn(
|
||||
'animate-popover z-50 overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-lg',
|
||||
)}
|
||||
style={{ width: isCollapsed ? '300px' : (buttonWidth ?? '300px') }}
|
||||
>
|
||||
<div className="py-1.5">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-primary" />
|
||||
<Ariakit.Combobox
|
||||
store={combobox}
|
||||
autoSelect
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full rounded-md bg-surface-secondary py-2 pl-9 pr-3 text-sm text-text-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-auto">
|
||||
<Ariakit.ComboboxList store={combobox}>
|
||||
<SelectRenderer store={select} items={matches} itemSize={ROW_HEIGHT} overscan={5}>
|
||||
{({ value, icon, label, ...item }) => (
|
||||
<Ariakit.ComboboxItem
|
||||
key={item.id}
|
||||
{...item}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center px-3 text-sm',
|
||||
'text-text-primary hover:bg-surface-tertiary',
|
||||
'data-[active-item]:bg-surface-tertiary',
|
||||
)}
|
||||
render={<Ariakit.SelectItem value={value} />}
|
||||
>
|
||||
{icon != null && iconSide === 'left' && (
|
||||
<div className={optionIconClassName}>{icon}</div>
|
||||
)}
|
||||
<span className="flex-grow truncate text-left">{label}</span>
|
||||
{icon != null && iconSide === 'right' && (
|
||||
<div className={optionIconClassName}>{icon}</div>
|
||||
)}
|
||||
</Ariakit.ComboboxItem>
|
||||
)}
|
||||
</SelectRenderer>
|
||||
</Ariakit.ComboboxList>
|
||||
</div>
|
||||
</Ariakit.SelectPopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ControlCombobox);
|
||||
468
packages/client/src/components/DataTable.tsx
Normal file
468
packages/client/src/components/DataTable.tsx
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import {
|
||||
Row,
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
VisibilityState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
} from '@tanstack/react-table';
|
||||
import type { Table as TTable } from '@tanstack/react-table';
|
||||
import { Table, TableRow, TableBody, TableCell, TableHead, TableHeader } from './Table';
|
||||
import AnimatedSearchInput from './AnimatedSearchInput';
|
||||
import { TrashIcon, Spinner } from '~/svgs';
|
||||
import { useMediaQuery } from '~/hooks';
|
||||
import { Skeleton } from './Skeleton';
|
||||
import { Checkbox } from './Checkbox';
|
||||
import { Button } from './Button';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||
meta?: {
|
||||
size?: string | number;
|
||||
mobileSize?: string | number;
|
||||
minWidth?: string | number;
|
||||
};
|
||||
};
|
||||
|
||||
const SelectionCheckbox = memo(
|
||||
({
|
||||
checked,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
ariaLabel: string;
|
||||
}) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="flex h-full w-[30px] items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
SelectionCheckbox.displayName = 'SelectionCheckbox';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: TableColumn<TData, TValue>[];
|
||||
data: TData[];
|
||||
onDelete?: (selectedRows: TData[]) => Promise<void>;
|
||||
filterColumn?: string;
|
||||
defaultSort?: SortingState;
|
||||
columnVisibilityMap?: Record<string, string>;
|
||||
className?: string;
|
||||
pageSize?: number;
|
||||
isFetchingNextPage?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
fetchNextPage?: (options?: unknown) => Promise<unknown>;
|
||||
enableRowSelection?: boolean;
|
||||
showCheckboxes?: boolean;
|
||||
onFilterChange?: (value: string) => void;
|
||||
filterValue?: string;
|
||||
isLoading?: boolean;
|
||||
enableSearch?: boolean;
|
||||
}
|
||||
|
||||
const TableRowComponent = <TData, TValue>({
|
||||
row,
|
||||
isSmallScreen,
|
||||
onSelectionChange,
|
||||
index,
|
||||
isSearching,
|
||||
}: {
|
||||
row: Row<TData>;
|
||||
isSmallScreen: boolean;
|
||||
onSelectionChange?: (rowId: string, selected: boolean) => void;
|
||||
index: number;
|
||||
isSearching: boolean;
|
||||
}) => {
|
||||
const handleSelection = useCallback(
|
||||
(value: boolean) => {
|
||||
row.toggleSelected(value);
|
||||
onSelectionChange?.(row.id, value);
|
||||
},
|
||||
[row, onSelectionChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
className="motion-safe:animate-fadeIn border-b border-border-light transition-all duration-300 ease-out hover:bg-surface-secondary"
|
||||
style={{
|
||||
animationDelay: `${index * 20}ms`,
|
||||
transform: `translateY(${isSearching ? '4px' : '0'})`,
|
||||
opacity: isSearching ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
if (cell.column.id === 'select') {
|
||||
return (
|
||||
<TableCell key={cell.id} className="px-2 py-1 transition-all duration-300">
|
||||
<SelectionCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={handleSelection}
|
||||
ariaLabel="Select row"
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="w-0 max-w-0 px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm"
|
||||
style={getColumnStyle(
|
||||
cell.column.columnDef as TableColumn<TData, TValue>,
|
||||
isSmallScreen,
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden text-ellipsis">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent;
|
||||
|
||||
function getColumnStyle<TData, TValue>(
|
||||
column: TableColumn<TData, TValue>,
|
||||
isSmallScreen: boolean,
|
||||
): React.CSSProperties {
|
||||
return {
|
||||
width: isSmallScreen ? column.meta?.mobileSize : column.meta?.size,
|
||||
minWidth: column.meta?.minWidth,
|
||||
maxWidth: column.meta?.size,
|
||||
};
|
||||
}
|
||||
|
||||
const DeleteButton = memo(
|
||||
({
|
||||
onDelete,
|
||||
isDeleting,
|
||||
disabled,
|
||||
isSmallScreen,
|
||||
}: {
|
||||
onDelete?: () => Promise<void>;
|
||||
isDeleting: boolean;
|
||||
disabled: boolean;
|
||||
isSmallScreen: boolean;
|
||||
}) => {
|
||||
if (!onDelete) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDelete}
|
||||
disabled={disabled}
|
||||
className={cn('min-w-[40px] transition-all duration-200', isSmallScreen && 'px-2 py-1')}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<>
|
||||
<TrashIcon className="size-3.5 text-red-400 sm:size-4" />
|
||||
{!isSmallScreen && <span className="ml-2">Delete</span>}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
onDelete,
|
||||
filterColumn,
|
||||
defaultSort = [],
|
||||
className = '',
|
||||
isFetchingNextPage = false,
|
||||
hasNextPage = false,
|
||||
fetchNextPage,
|
||||
enableRowSelection = true,
|
||||
showCheckboxes = true,
|
||||
onFilterChange,
|
||||
filterValue,
|
||||
isLoading,
|
||||
enableSearch = true,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [searchTerm, setSearchTerm] = useState(filterValue ?? '');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
if (!enableRowSelection || !showCheckboxes) {
|
||||
return columns;
|
||||
}
|
||||
const selectColumn = {
|
||||
id: 'select',
|
||||
header: ({ table }: { table: TTable<TData> }) => (
|
||||
<div className="flex h-full w-[30px] items-center justify-center">
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }: { row: Row<TData> }) => (
|
||||
<SelectionCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={(value) => row.toggleSelected(value)}
|
||||
ariaLabel="Select row"
|
||||
/>
|
||||
),
|
||||
meta: { size: '50px' },
|
||||
};
|
||||
return [selectColumn, ...columns];
|
||||
}, [columns, enableRowSelection, showCheckboxes]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: tableColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
enableRowSelection,
|
||||
enableMultiRowSelection: true,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
});
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: useCallback(() => 48, []),
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
|
||||
const paddingBottom =
|
||||
virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0;
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = tableContainerRef.current;
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = async () => {
|
||||
if (!hasNextPage || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
|
||||
try {
|
||||
// Safely fetch next page without breaking if lastPage is undefined
|
||||
await fetchNextPage?.();
|
||||
} catch (error) {
|
||||
console.error('Unable to fetch next page:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsSearching(true);
|
||||
const timeout = setTimeout(() => {
|
||||
onFilterChange?.(searchTerm);
|
||||
setIsSearching(false);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchTerm, onFilterChange]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!onDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const itemsToDelete = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
|
||||
await onDelete(itemsToDelete);
|
||||
setRowSelection({});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [onDelete, table]);
|
||||
|
||||
const getRandomWidth = () => Math.floor(Math.random() * (410 - 170 + 1)) + 170;
|
||||
|
||||
const skeletons = Array.from({ length: 13 }, (_, index) => {
|
||||
const randomWidth = getRandomWidth();
|
||||
const firstDataColumnIndex = tableColumns[0]?.id === 'select' ? 1 : 0;
|
||||
|
||||
return (
|
||||
<TableRow key={index} className="motion-safe:animate-fadeIn border-b border-border-light">
|
||||
{tableColumns.map((column, columnIndex) => {
|
||||
const style = getColumnStyle(column as TableColumn<TData, TValue>, isSmallScreen);
|
||||
const isFirstDataColumn = columnIndex === firstDataColumnIndex;
|
||||
|
||||
return (
|
||||
<TableCell key={column.id} className="px-2 py-1 sm:px-4 sm:py-2" style={style}>
|
||||
<Skeleton
|
||||
className="h-6"
|
||||
style={isFirstDataColumn ? { width: `${randomWidth}px` } : { width: '100%' }}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col gap-4', className)}>
|
||||
{/* Table controls */}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-4">
|
||||
{enableRowSelection && showCheckboxes && (
|
||||
<DeleteButton
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
)}
|
||||
{filterColumn !== undefined && table.getColumn(filterColumn) && enableSearch && (
|
||||
<div className="relative flex-1">
|
||||
<AnimatedSearchInput
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
isSearching={isSearching}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Virtualized table */}
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className={cn(
|
||||
'relative h-[calc(100vh-20rem)] max-w-full overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10',
|
||||
'transition-all duration-300 ease-out',
|
||||
isSearching && 'bg-surface-secondary/50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Table className="w-full min-w-[300px] table-fixed border-separate border-spacing-0">
|
||||
<TableHeader className="sticky top-0 z-50 bg-surface-secondary">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="border-b border-border-light">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary sm:px-4"
|
||||
style={getColumnStyle(
|
||||
header.column.columnDef as TableColumn<TData, TValue>,
|
||||
isSmallScreen,
|
||||
)}
|
||||
onClick={
|
||||
header.column.getCanSort()
|
||||
? header.column.getToggleSortingHandler()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{paddingTop > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingTop}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{isLoading && skeletons}
|
||||
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<MemoizedTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
isSmallScreen={isSmallScreen}
|
||||
index={virtualRow.index}
|
||||
isSearching={isSearching}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{!virtualRows.length && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="p-4 text-center">
|
||||
No data available
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{paddingBottom > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingBottom}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{(isFetchingNextPage || hasNextPage) && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="p-4">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
hasNextPage && <div className="h-6" />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
packages/client/src/components/DataTableColumnHeader.tsx
Normal file
60
packages/client/src/components/DataTableColumnHeader.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { Column } from '@tanstack/react-table';
|
||||
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './DropdownMenu';
|
||||
import { Button } from './Button';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className = '',
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="-ml-3 h-8 data-[state=open]:bg-accent">
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDownIcon className="ml-2 h-4 w-4" />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUpIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="z-[1001]">
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
packages/client/src/components/DelayedRender.tsx
Normal file
12
packages/client/src/components/DelayedRender.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import { useDelayedRender } from '~/hooks';
|
||||
|
||||
interface DelayedRenderProps {
|
||||
delay: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const DelayedRender = ({ delay, children }: DelayedRenderProps) =>
|
||||
useDelayedRender(delay)(() => children);
|
||||
|
||||
export default DelayedRender;
|
||||
170
packages/client/src/components/Dialog.tsx
Normal file
170
packages/client/src/components/Dialog.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useMediaQuery } from '~/hooks';
|
||||
import { Button } from './Button';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
type DialogPortalProps = DialogPrimitive.DialogPortalProps & { className?: string };
|
||||
|
||||
const DialogPortal = ({ className = '', children, ...props }: DialogPortalProps) => (
|
||||
<DialogPrimitive.Portal className={cn(className)} {...(props as DialogPortalProps)}>
|
||||
<div className="fixed inset-0 z-[999] flex items-start justify-center sm:items-center">
|
||||
{children}
|
||||
</div>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
DialogPortal.displayName = DialogPrimitive.Portal.displayName;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-[999] bg-gray-600/65 transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in dark:bg-black/80',
|
||||
className ?? '',
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
disableScroll?: boolean;
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(
|
||||
(
|
||||
{ className, children = true, showCloseButton = true, disableScroll = false, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed z-[999] grid w-full gap-4 rounded-b-lg bg-white pb-6 animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:rounded-lg',
|
||||
'dark:bg-gray-700',
|
||||
isSmallScreen
|
||||
? 'fixed left-1/2 top-1/2 z-[999] m-auto grid w-11/12 -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-white pb-6'
|
||||
: '',
|
||||
disableScroll ? 'overflow-hidden' : '',
|
||||
className ?? '',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-6 top-[1.6rem] rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-gray-100 dark:focus:ring-white dark:focus:ring-offset-gray-700 dark:data-[state=open]:bg-gray-800">
|
||||
<X className="h-5 w-5 text-black dark:text-white" />
|
||||
<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-2 border-b border-black/10 p-6 pb-4 text-left dark:border-white/10',
|
||||
className ?? '',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-row justify-between space-x-2 px-6 py-4', 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 text-gray-900', 'dark:text-gray-50', 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-sm text-gray-500', 'dark:text-gray-400', className ?? '')}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
const DialogClose = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 inline-flex h-10 items-center justify-center rounded-lg border border-gray-200 bg-transparent px-4 py-2 text-sm font-semibold text-gray-900 transition-colors hover:bg-gray-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800 sm:mt-0',
|
||||
className ?? '',
|
||||
/* Important: for accessibility */
|
||||
'focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogClose.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogButton = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentPropsWithoutRef<typeof Button>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'mt-2 inline-flex h-10 items-center justify-center rounded-lg border border-gray-200 bg-transparent px-4 py-2 text-sm font-semibold text-gray-900 transition-colors hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900 sm:mt-0',
|
||||
className ?? '',
|
||||
/* Important: for accessibility */
|
||||
'focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogButton.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogClose,
|
||||
DialogButton,
|
||||
};
|
||||
88
packages/client/src/components/DialogTemplate.spec.tsx
Normal file
88
packages/client/src/components/DialogTemplate.spec.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import 'test/matchMedia.mock';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import DialogTemplate from './DialogTemplate';
|
||||
import { Dialog } from '@radix-ui/react-dialog';
|
||||
import { Provider } from 'jotai';
|
||||
|
||||
describe('DialogTemplate', () => {
|
||||
let mockSelectHandler: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSelectHandler = jest.fn();
|
||||
});
|
||||
|
||||
it('renders correctly with all props', () => {
|
||||
const { getByText } = render(
|
||||
<Provider>
|
||||
<Dialog
|
||||
open
|
||||
data-testid="test-dialog"
|
||||
onOpenChange={() => {
|
||||
return;
|
||||
}}
|
||||
>
|
||||
<DialogTemplate
|
||||
title="Test Dialog"
|
||||
description="Test Description"
|
||||
main={<div>Main Content</div>}
|
||||
buttons={<button>Button</button>}
|
||||
leftButtons={<button>Left Button</button>}
|
||||
selection={{ selectHandler: mockSelectHandler, selectText: 'Select' }}
|
||||
/>
|
||||
</Dialog>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(getByText('Test Dialog')).toBeInTheDocument();
|
||||
expect(getByText('Test Description')).toBeInTheDocument();
|
||||
expect(getByText('Main Content')).toBeInTheDocument();
|
||||
expect(getByText('Button')).toBeInTheDocument();
|
||||
expect(getByText('Left Button')).toBeInTheDocument();
|
||||
expect(getByText('Cancel')).toBeInTheDocument();
|
||||
expect(getByText('Select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correctly without optional props', () => {
|
||||
const { queryByText } = render(
|
||||
<Provider>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={() => {
|
||||
return;
|
||||
}}
|
||||
></Dialog>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(queryByText('Test Dialog')).toBeNull();
|
||||
expect(queryByText('Test Description')).not.toBeInTheDocument();
|
||||
expect(queryByText('Main Content')).not.toBeInTheDocument();
|
||||
expect(queryByText('Button')).not.toBeInTheDocument();
|
||||
expect(queryByText('Left Button')).not.toBeInTheDocument();
|
||||
expect(queryByText('Cancel')).not.toBeInTheDocument();
|
||||
expect(queryByText('Select')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls selectHandler when the select button is clicked', () => {
|
||||
const { getByText } = render(
|
||||
<Provider>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={() => {
|
||||
return;
|
||||
}}
|
||||
>
|
||||
<DialogTemplate
|
||||
title="Test Dialog"
|
||||
selection={{ selectHandler: mockSelectHandler, selectText: 'Select' }}
|
||||
/>
|
||||
</Dialog>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByText('Select'));
|
||||
|
||||
expect(mockSelectHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
94
packages/client/src/components/DialogTemplate.tsx
Normal file
94
packages/client/src/components/DialogTemplate.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { forwardRef, ReactNode, Ref } from 'react';
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './Dialog';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
type SelectionProps = {
|
||||
selectHandler?: () => void;
|
||||
selectClasses?: string;
|
||||
selectText?: string;
|
||||
};
|
||||
|
||||
type DialogTemplateProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
main?: ReactNode;
|
||||
buttons?: ReactNode;
|
||||
leftButtons?: ReactNode;
|
||||
selection?: SelectionProps;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
footerClassName?: string;
|
||||
showCloseButton?: boolean;
|
||||
showCancelButton?: boolean;
|
||||
};
|
||||
|
||||
const DialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDivElement>) => {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
main,
|
||||
buttons,
|
||||
leftButtons,
|
||||
selection,
|
||||
className,
|
||||
headerClassName,
|
||||
footerClassName,
|
||||
showCloseButton,
|
||||
showCancelButton = true,
|
||||
} = props;
|
||||
const { selectHandler, selectClasses, selectText } = selection || {};
|
||||
const Cancel = 'cancel';
|
||||
|
||||
const defaultSelect =
|
||||
'bg-gray-800 text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-gray-200';
|
||||
return (
|
||||
<DialogContent
|
||||
showCloseButton={showCloseButton}
|
||||
ref={ref}
|
||||
className={cn('shadow-2xl dark:bg-gray-700', className || '')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogHeader className={cn(headerClassName ?? '')}>
|
||||
<DialogTitle className="text-lg font-medium leading-6 text-gray-800 dark:text-gray-200">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
{description && (
|
||||
<DialogDescription className="text-gray-600 dark:text-gray-300">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div className="px-6">{main ? main : null}</div>
|
||||
<DialogFooter className={footerClassName}>
|
||||
<div>{leftButtons ? leftButtons : null}</div>
|
||||
<div className="flex h-auto gap-3">
|
||||
{showCancelButton && (
|
||||
<DialogClose className="border-gray-100 hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-600">
|
||||
{Cancel}
|
||||
</DialogClose>
|
||||
)}
|
||||
{buttons ? buttons : null}
|
||||
{selection ? (
|
||||
<DialogClose
|
||||
onClick={selectHandler}
|
||||
className={`${
|
||||
selectClasses || defaultSelect
|
||||
} inline-flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm`}
|
||||
>
|
||||
{selectText}
|
||||
</DialogClose>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
});
|
||||
|
||||
export default DialogTemplate;
|
||||
150
packages/client/src/components/Dropdown.tsx
Normal file
150
packages/client/src/components/Dropdown.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import React from 'react';
|
||||
import * as Select from '@ariakit/react/select';
|
||||
import type { Option } from '~/common';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
interface DropdownProps {
|
||||
value?: string;
|
||||
label?: string;
|
||||
onChange: (value: string) => void;
|
||||
options: (string | Option | { divider: true })[];
|
||||
className?: string;
|
||||
sizeClasses?: string;
|
||||
testId?: string;
|
||||
icon?: React.ReactNode;
|
||||
iconOnly?: boolean;
|
||||
renderValue?: (option: Option) => React.ReactNode;
|
||||
ariaLabel?: string;
|
||||
portal?: boolean;
|
||||
}
|
||||
|
||||
const isDivider = (item: string | Option | { divider: true }): item is { divider: true } =>
|
||||
typeof item === 'object' && 'divider' in item;
|
||||
|
||||
const isOption = (item: string | Option | { divider: true }): item is Option =>
|
||||
typeof item === 'object' && 'value' in item && 'label' in item;
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = ({
|
||||
value: selectedValue,
|
||||
label = '',
|
||||
onChange,
|
||||
options,
|
||||
className = '',
|
||||
sizeClasses,
|
||||
testId = 'dropdown-menu',
|
||||
icon,
|
||||
iconOnly = false,
|
||||
renderValue,
|
||||
ariaLabel,
|
||||
portal = true,
|
||||
}) => {
|
||||
const handleChange = (value: string) => {
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
const selectProps = Select.useSelectStore({
|
||||
value: selectedValue,
|
||||
setValue: handleChange,
|
||||
});
|
||||
|
||||
const getOptionObject = (val: string | undefined): Option | undefined => {
|
||||
if (val == null || val === '') {
|
||||
return undefined;
|
||||
}
|
||||
return options
|
||||
.filter((o) => !isDivider(o))
|
||||
.map((o) => (typeof o === 'string' ? { value: o, label: o } : o))
|
||||
.find((o) => isOption(o) && o.value === val) as Option | undefined;
|
||||
};
|
||||
|
||||
const getOptionLabel = (currentValue: string | undefined) => {
|
||||
if (currentValue == null || currentValue === '') {
|
||||
return '';
|
||||
}
|
||||
const option = getOptionObject(currentValue);
|
||||
return option ? option.label : currentValue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Select.Select
|
||||
store={selectProps}
|
||||
className={cn(
|
||||
'focus:ring-offset-ring-offset relative inline-flex items-center justify-between rounded-xl border border-input bg-background px-3 py-2 text-sm text-text-primary transition-all duration-200 ease-in-out hover:bg-accent hover:text-accent-foreground focus:ring-ring-primary',
|
||||
iconOnly ? 'h-full w-10' : 'w-fit gap-2',
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{icon}
|
||||
{!iconOnly && (
|
||||
<span className="block truncate">
|
||||
{label}
|
||||
{(() => {
|
||||
const matchedOption = getOptionObject(selectedValue);
|
||||
if (matchedOption && renderValue) {
|
||||
return renderValue(matchedOption);
|
||||
}
|
||||
return getOptionLabel(selectedValue);
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!iconOnly && <Select.SelectArrow />}
|
||||
</Select.Select>
|
||||
<Select.SelectPopover
|
||||
portal={portal}
|
||||
store={selectProps}
|
||||
className={cn('popover-ui', sizeClasses, className, 'max-h-[80vh] overflow-y-auto')}
|
||||
>
|
||||
{options.map((item, index) => {
|
||||
if (isDivider(item)) {
|
||||
return <div key={`divider-${index}`} className="my-1 border-t border-border-heavy" />;
|
||||
}
|
||||
|
||||
const option = typeof item === 'string' ? { value: item, label: item } : item;
|
||||
if (!isOption(option)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select.SelectItem
|
||||
key={`option-${index}`}
|
||||
value={String(option.value)}
|
||||
className="select-item"
|
||||
data-theme={option.value}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{option.icon != null && <span>{option.icon as React.ReactNode}</span>}
|
||||
<span className="block truncate">{option.label}</span>
|
||||
{selectedValue === option.value && (
|
||||
<span className="ml-auto pl-2">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md block group-hover:hidden"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Select.SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select.SelectPopover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
191
packages/client/src/components/DropdownMenu.tsx
Normal file
191
packages/client/src/components/DropdownMenu.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import { cn } from '~/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(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-gray-100 data-[state=open]:bg-gray-100 dark:focus:bg-gray-900 dark:data-[state=open]:bg-gray-900',
|
||||
inset ? 'pl-8' : '',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-100 bg-white p-1 text-gray-700 shadow-md animate-in slide-in-from-left-1 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className = '', sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-100 bg-white p-1 text-gray-700 shadow-md animate-in 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 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-400',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className = '', inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-900',
|
||||
inset ? 'pl-8' : '',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className = '', children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-900',
|
||||
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(
|
||||
className,
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none focus:bg-gray-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800',
|
||||
)}
|
||||
{...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 text-gray-900 dark:text-gray-300',
|
||||
inset ? 'pl-8' : '',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className = '',
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span className={cn('ml-auto text-xs tracking-widest text-gray-500', className)} {...props} />
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
136
packages/client/src/components/DropdownNoState.tsx
Normal file
136
packages/client/src/components/DropdownNoState.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import React, { FC } from 'react';
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import type { Option } from '~/common';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
interface DropdownProps {
|
||||
value?: string | Option;
|
||||
label?: string;
|
||||
onChange: (value: string | Option) => void;
|
||||
options: (string | Option)[];
|
||||
className?: string;
|
||||
sizeClasses?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* Mainly used for the Speech Voice Selection Dropdown
|
||||
*/
|
||||
|
||||
const Dropdown: FC<DropdownProps> = ({
|
||||
value,
|
||||
label = '',
|
||||
onChange,
|
||||
options,
|
||||
className = '',
|
||||
sizeClasses,
|
||||
testId = 'dropdown-menu',
|
||||
}) => {
|
||||
const getValue = (option?: string | Option) =>
|
||||
typeof option === 'string' ? option : option?.value;
|
||||
|
||||
const getDisplay = (option?: string | Option) =>
|
||||
typeof option === 'string' ? option : (option?.label ?? option?.value);
|
||||
|
||||
const isEqual = (a: string | Option, b: string | Option): boolean => getValue(a) === getValue(b);
|
||||
|
||||
const selectedOption = options.find((option) => isEqual(option, value ?? '')) ?? value;
|
||||
|
||||
const handleChange = (newValue: string | Option) => {
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Listbox value={selectedOption} onChange={handleChange}>
|
||||
<div className={cn('relative', className)}>
|
||||
<ListboxButton
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-between rounded-md border-gray-50 bg-white py-2 pl-3 pr-8 text-black transition-all duration-100 ease-in-out hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 dark:focus:ring-white dark:focus:ring-offset-gray-700',
|
||||
'w-auto',
|
||||
className,
|
||||
)}
|
||||
aria-label="Select an option"
|
||||
>
|
||||
<span className="block truncate">
|
||||
{label}
|
||||
{getDisplay(selectedOption)}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
className="h-4 w-5 rotate-0 transform text-black transition-transform duration-300 ease-in-out dark:text-gray-50"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
leave="transition ease-in duration-50"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
className={cn(
|
||||
'absolute z-50 mt-1 flex flex-col items-start gap-1 overflow-auto rounded-lg border border-gray-300 bg-white p-1.5 text-gray-700 shadow-lg transition-opacity focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
||||
sizeClasses,
|
||||
className,
|
||||
)}
|
||||
aria-label="List of options"
|
||||
>
|
||||
{options.map((item, index) => (
|
||||
<ListboxOption
|
||||
key={index}
|
||||
value={item}
|
||||
className={cn(
|
||||
'relative cursor-pointer select-none rounded border-gray-300 bg-white py-2.5 pl-3 pr-3 text-sm text-gray-700 hover:bg-gray-100 dark:border-gray-300 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600',
|
||||
)}
|
||||
style={{ width: '100%' }}
|
||||
data-theme={getValue(item)}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="block truncate">{getDisplay(item)}</span>
|
||||
{selected && (
|
||||
<span className="ml-auto pl-2">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md block group-hover:hidden"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
133
packages/client/src/components/DropdownPopup.tsx
Normal file
133
packages/client/src/components/DropdownPopup.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import React from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import type * as t from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface DropdownProps {
|
||||
keyPrefix?: string;
|
||||
trigger: React.ReactNode;
|
||||
items: t.MenuItemProps[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
itemClassName?: string;
|
||||
sameWidth?: boolean;
|
||||
anchor?: { x: string; y: string };
|
||||
gutter?: number;
|
||||
modal?: boolean;
|
||||
portal?: boolean;
|
||||
preserveTabOrder?: boolean;
|
||||
focusLoop?: boolean;
|
||||
menuId: string;
|
||||
mountByState?: boolean;
|
||||
unmountOnHide?: boolean;
|
||||
finalFocus?: React.RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
type MenuProps = Omit<
|
||||
DropdownProps,
|
||||
'trigger' | 'isOpen' | 'setIsOpen' | 'focusLoop' | 'mountByState'
|
||||
>;
|
||||
|
||||
const DropdownPopup: React.FC<DropdownProps> = ({
|
||||
trigger,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
focusLoop,
|
||||
mountByState,
|
||||
...props
|
||||
}) => {
|
||||
const menu = Ariakit.useMenuStore({ open: isOpen, setOpen: setIsOpen, focusLoop });
|
||||
if (mountByState) {
|
||||
return (
|
||||
<Ariakit.MenuProvider store={menu}>
|
||||
{trigger}
|
||||
{isOpen && <Menu {...props} />}
|
||||
</Ariakit.MenuProvider>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Ariakit.MenuProvider store={menu}>
|
||||
{trigger}
|
||||
<Menu {...props} />
|
||||
</Ariakit.MenuProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const Menu: React.FC<MenuProps> = ({
|
||||
items,
|
||||
menuId,
|
||||
keyPrefix,
|
||||
className,
|
||||
iconClassName,
|
||||
itemClassName,
|
||||
modal,
|
||||
portal,
|
||||
sameWidth,
|
||||
gutter = 8,
|
||||
finalFocus,
|
||||
unmountOnHide,
|
||||
preserveTabOrder,
|
||||
}) => {
|
||||
const menu = Ariakit.useMenuContext();
|
||||
return (
|
||||
<Ariakit.Menu
|
||||
id={menuId}
|
||||
modal={modal}
|
||||
gutter={gutter}
|
||||
portal={portal}
|
||||
sameWidth={sameWidth}
|
||||
finalFocus={finalFocus}
|
||||
unmountOnHide={unmountOnHide}
|
||||
preserveTabOrder={preserveTabOrder}
|
||||
className={cn('popover-ui z-50', className)}
|
||||
>
|
||||
{items
|
||||
.filter((item) => item.show !== false)
|
||||
.map((item, index) => {
|
||||
if (item.separate === true) {
|
||||
return <Ariakit.MenuSeparator key={index} className="my-1 h-px bg-white/10" />;
|
||||
}
|
||||
return (
|
||||
<Ariakit.MenuItem
|
||||
key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}`}
|
||||
id={item.id}
|
||||
className={cn(
|
||||
'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
|
||||
itemClassName,
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
render={item.render}
|
||||
ref={item.ref}
|
||||
hideOnClick={item.hideOnClick}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (item.onClick) {
|
||||
item.onClick(event);
|
||||
}
|
||||
if (item.hideOnClick === false) {
|
||||
return;
|
||||
}
|
||||
menu?.hide();
|
||||
}}
|
||||
>
|
||||
{item.icon != null && (
|
||||
<span className={cn('mr-2 size-4', iconClassName)} aria-hidden="true">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
{item.label}
|
||||
{item.kbd != null && (
|
||||
<kbd className="ml-auto hidden font-sans text-xs text-black/50 group-hover:inline group-focus:inline dark:text-white/50">
|
||||
⌘{item.kbd}
|
||||
</kbd>
|
||||
)}
|
||||
</Ariakit.MenuItem>
|
||||
);
|
||||
})}
|
||||
</Ariakit.Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownPopup;
|
||||
29
packages/client/src/components/FileUpload.tsx
Normal file
29
packages/client/src/components/FileUpload.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
|
||||
type FileUploadProps = {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const FileUpload = forwardRef<HTMLInputElement, FileUploadProps>(
|
||||
({ children, handleFileChange }, ref) => {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<input
|
||||
ref={ref}
|
||||
multiple
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FileUpload.displayName = 'FileUpload';
|
||||
|
||||
export default FileUpload;
|
||||
67
packages/client/src/components/FormInput.tsx
Normal file
67
packages/client/src/components/FormInput.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import React from 'react';
|
||||
import type { ControllerRenderProps, FieldValues, FieldPath } from 'react-hook-form';
|
||||
import { Label } from './Label';
|
||||
import { Input } from './Input';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function FormInput<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
field,
|
||||
label,
|
||||
labelClass,
|
||||
inputClass,
|
||||
containerClass,
|
||||
labelAdjacent,
|
||||
placeholder = '',
|
||||
type = 'string',
|
||||
}: {
|
||||
field: ControllerRenderProps<TFieldValues, TName>;
|
||||
label: string;
|
||||
labelClass?: string;
|
||||
inputClass?: string;
|
||||
placeholder?: string;
|
||||
containerClass?: string;
|
||||
type?: 'string' | 'number';
|
||||
labelAdjacent?: React.ReactNode;
|
||||
}) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
|
||||
if (type !== 'number') {
|
||||
field.onChange(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
field.onChange(value);
|
||||
} else if (!isNaN(Number(value))) {
|
||||
field.onChange(Number(value));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full flex-col items-center gap-2', containerClass)}>
|
||||
<div className="flex w-full items-center justify-start gap-2">
|
||||
<Label
|
||||
htmlFor={`${field.name}-input`}
|
||||
className={cn('text-left text-sm font-semibold text-text-primary', labelClass)}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
{labelAdjacent}
|
||||
</div>
|
||||
<Input
|
||||
id={`${field.name}-input`}
|
||||
value={field.value ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'flex h-10 max-h-10 w-full resize-none border-none bg-surface-secondary px-3 py-2',
|
||||
inputClass,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
packages/client/src/components/HoverCard.tsx
Normal file
34
packages/client/src/components/HoverCard.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import * as React from 'react';
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardPortal = HoverCardPrimitive.Portal;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> & { disabled?: boolean }
|
||||
>(({ className = '', align = 'center', sideOffset = 6, disabled = false, ...props }, ref) => {
|
||||
if (disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-64 origin-[--radix-hover-card-content-transform-origin] rounded-xl border border-border-light bg-surface-secondary p-4 text-text-primary shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent, HoverCardPortal };
|
||||
21
packages/client/src/components/Input.tsx
Normal file
21
packages/client/src/components/Input.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className ?? '',
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
105
packages/client/src/components/InputCombobox.tsx
Normal file
105
packages/client/src/components/InputCombobox.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type ComboboxProps = {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
options: OptionWithIcon[] | string[];
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur: () => void;
|
||||
};
|
||||
|
||||
export const InputCombobox: React.FC<ComboboxProps> = ({
|
||||
label,
|
||||
labelClassName,
|
||||
placeholder = 'Select an option',
|
||||
options,
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
}) => {
|
||||
const isOptionObject = (option: unknown): option is OptionWithIcon => {
|
||||
return option != null && typeof option === 'object' && 'value' in option;
|
||||
};
|
||||
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [inputValue, setInputValue] = React.useState(value);
|
||||
const [isKeyboardFocus, setIsKeyboardFocus] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setInputValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
setInputValue(newValue);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Ariakit.ComboboxProvider value={inputValue} setValue={handleChange}>
|
||||
{label != null && (
|
||||
<Ariakit.ComboboxLabel
|
||||
className={cn('mb-2 block text-sm font-medium text-text-primary', labelClassName ?? '')}
|
||||
>
|
||||
{label}
|
||||
</Ariakit.ComboboxLabel>
|
||||
)}
|
||||
<div className={cn('relative', isKeyboardFocus ? 'rounded-md ring-2 ring-ring-primary' : '')}>
|
||||
<Ariakit.Combobox
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'h-10 w-full rounded-md border border-border-light bg-surface-primary px-3 py-2 text-sm',
|
||||
'placeholder-text-secondary hover:bg-surface-hover',
|
||||
'focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
onChange={(event) => handleChange(event.target.value)}
|
||||
onBlur={() => {
|
||||
setIsKeyboardFocus(false);
|
||||
onBlur();
|
||||
}}
|
||||
onFocusVisible={() => {
|
||||
setIsKeyboardFocus(true);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
setIsKeyboardFocus(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Ariakit.ComboboxPopover
|
||||
gutter={4}
|
||||
sameWidth
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
className={cn(
|
||||
'z-50 max-h-60 w-full overflow-auto rounded-md bg-surface-primary p-1 shadow-lg',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
)}
|
||||
>
|
||||
{options.map((option: string | OptionWithIcon, index: number) => (
|
||||
<Ariakit.ComboboxItem
|
||||
key={index}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
|
||||
'cursor-pointer hover:bg-surface-tertiary hover:text-text-primary',
|
||||
'data-[active-item]:bg-surface-tertiary data-[active-item]:text-text-primary',
|
||||
)}
|
||||
value={isOptionObject(option) ? `${option.value ?? ''}` : option}
|
||||
>
|
||||
{isOptionObject(option) && option.icon != null && (
|
||||
<span className="mr-2 flex-shrink-0">{option.icon}</span>
|
||||
)}
|
||||
{isOptionObject(option) ? option.label : option}
|
||||
</Ariakit.ComboboxItem>
|
||||
))}
|
||||
</Ariakit.ComboboxPopover>
|
||||
</Ariakit.ComboboxProvider>
|
||||
);
|
||||
};
|
||||
45
packages/client/src/components/InputNumber.tsx
Normal file
45
packages/client/src/components/InputNumber.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import * as React from 'react';
|
||||
|
||||
// import { NumericFormat } from 'react-number-format';
|
||||
|
||||
import RCInputNumber from 'rc-input-number';
|
||||
import * as InputNumberPrimitive from 'rc-input-number';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
// TODO help needed
|
||||
// React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
// React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
|
||||
const InputNumber = React.forwardRef<
|
||||
React.ElementRef<typeof RCInputNumber>,
|
||||
InputNumberPrimitive.InputNumberProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RCInputNumber
|
||||
className={cn(
|
||||
'flex max-h-5 w-full rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-50',
|
||||
className ?? '',
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
InputNumber.displayName = 'Input';
|
||||
|
||||
// console.log(_InputNumber);
|
||||
|
||||
// const InputNumber = React.forwardRef(({ className, ...props }, ref) => {
|
||||
// return (
|
||||
// <NumericFormat
|
||||
// className={cn(
|
||||
// 'flex h-10 w-full rounded-md border border-gray-300 bg-transparent py-2 px-3 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-50 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
|
||||
// className
|
||||
// )}
|
||||
// ref={ref}
|
||||
// {...props}
|
||||
// />
|
||||
// );
|
||||
// });
|
||||
|
||||
export { InputNumber };
|
||||
73
packages/client/src/components/InputOTP.tsx
Normal file
73
packages/client/src/components/InputOTP.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import * as React from 'react';
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
import { Minus } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
'flex items-center gap-2 has-[:disabled]:opacity-50',
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
InputOTP.displayName = 'InputOTP';
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center', className)} {...props} />
|
||||
));
|
||||
InputOTPGroup.displayName = 'InputOTPGroup';
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
|
||||
if (!inputOTPContext) {
|
||||
throw new Error('InputOTPSlot must be used within an OTPInput');
|
||||
}
|
||||
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-md relative flex h-11 w-11 items-center justify-center border-y border-r border-input shadow-sm transition-all first:rounded-l-xl first:border-l last:rounded-r-xl',
|
||||
isActive && 'z-10 ring-1 ring-ring',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
InputOTPSlot.displayName = 'InputOTPSlot';
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Minus />
|
||||
</div>
|
||||
));
|
||||
InputOTPSeparator.displayName = 'InputOTPSeparator';
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
154
packages/client/src/components/InputWithDropDown.tsx
Normal file
154
packages/client/src/components/InputWithDropDown.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import * as React from 'react';
|
||||
import { Input } from './Input';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export type InputWithDropdownProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
options: string[];
|
||||
onSelect?: (value: string) => void;
|
||||
};
|
||||
|
||||
const InputWithDropdown = React.forwardRef<HTMLInputElement, InputWithDropdownProps>(
|
||||
({ className, options, onSelect, ...props }, ref) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [inputValue, setInputValue] = React.useState((props.value as string) || '');
|
||||
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
setInputValue(value);
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
if (onSelect) {
|
||||
onSelect(value);
|
||||
}
|
||||
if (props.onChange) {
|
||||
props.onChange({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
if (props.onChange) {
|
||||
props.onChange(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
setHighlightedIndex((prevIndex) =>
|
||||
prevIndex < options.length - 1 ? prevIndex + 1 : prevIndex,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (isOpen && highlightedIndex !== -1) {
|
||||
handleSelect(options[highlightedIndex]);
|
||||
}
|
||||
setIsOpen(false);
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (inputRef.current && !inputRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={inputRef}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...props}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls="dropdown-list"
|
||||
className={cn('bg-surface-secondary', className ?? '')}
|
||||
ref={ref}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-tertiary absolute inset-y-0 right-0 flex items-center rounded-md px-2 hover:text-secondary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring-primary"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label={isOpen ? 'Close dropdown' : 'Open dropdown'}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ul
|
||||
id="dropdown-list"
|
||||
role="listbox"
|
||||
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border-medium bg-surface-secondary shadow-lg focus:ring-1 focus:ring-inset focus:ring-ring-primary"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<li
|
||||
key={index}
|
||||
role="option"
|
||||
aria-selected={index === highlightedIndex}
|
||||
className={cn(
|
||||
'cursor-pointer rounded-md px-3 py-2',
|
||||
'focus:bg-surface-tertiary focus:outline-none focus:ring-1 focus:ring-inset focus:ring-ring-primary',
|
||||
index === highlightedIndex
|
||||
? 'bg-surface-active text-primary'
|
||||
: 'text-secondary hover:bg-surface-tertiary',
|
||||
)}
|
||||
onClick={() => handleSelect(option)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelect(option);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
{option}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
InputWithDropdown.displayName = 'InputWithDropdown';
|
||||
|
||||
export default InputWithDropdown;
|
||||
24
packages/client/src/components/Label.tsx
Normal file
24
packages/client/src/components/Label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
{...props}
|
||||
{...{
|
||||
className: cn(
|
||||
'block w-full break-all text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-200',
|
||||
className,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
155
packages/client/src/components/MultiSearch.tsx
Normal file
155
packages/client/src/components/MultiSearch.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { Search, X } from 'lucide-react';
|
||||
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/** This is a generic that can be added to Menu and Select components */
|
||||
export default function MultiSearch({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className = '',
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (filter: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onChangeHandler: React.ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(e) => onChange(e.target.value),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const clearSearch = () => {
|
||||
onChange('');
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'focus:to-surface-primary/50 group sticky left-0 top-0 z-10 flex h-12 items-center gap-2 bg-gradient-to-b from-surface-tertiary-alt from-65% to-transparent px-3 py-2 text-text-primary transition-colors duration-300 focus:bg-gradient-to-b focus:from-surface-primary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Search
|
||||
className="h-4 w-4 text-text-secondary-alt transition-colors duration-300"
|
||||
aria-hidden={'true'}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={onChangeHandler}
|
||||
placeholder={String(placeholder ?? 'Search...')}
|
||||
aria-label="Search Model"
|
||||
className="flex-1 rounded-md border-none bg-transparent px-2.5 py-2 text-sm placeholder-text-secondary focus:outline-none focus:ring-1 focus:ring-ring-primary"
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex h-5 w-5 items-center justify-end rounded-md text-text-secondary-alt',
|
||||
(value?.length ?? 0) ? 'cursor-pointer opacity-100' : 'hidden',
|
||||
)}
|
||||
aria-label={'Clear search'}
|
||||
onClick={clearSearch}
|
||||
tabIndex={0}
|
||||
>
|
||||
<X
|
||||
aria-hidden={'true'}
|
||||
className={cn(
|
||||
'text-text-secondary-alt',
|
||||
(value?.length ?? 0) ? 'cursor-pointer opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function that will take a multiSearch input
|
||||
* @param node
|
||||
*/
|
||||
function defaultGetStringKey(node: unknown): string {
|
||||
if (typeof node === 'string') {
|
||||
// BUGFIX: Detect psedeo separators and make sure they don't appear in the list when filtering items
|
||||
// it makes sure (for the most part) that the model name starts and ends with dashes
|
||||
// The long-term fix here would be to enable seperators (model groupings) but there's no
|
||||
// feature mocks for such a thing yet
|
||||
if (node.startsWith('---') && node.endsWith('---')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return node.toUpperCase();
|
||||
}
|
||||
// This should be a noop, but it's here for redundancy
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for conditionally making a multi-element list component into a sortable component
|
||||
* Returns a RenderNode for search input when search functionality is available
|
||||
* @param availableOptions
|
||||
* @param placeholder
|
||||
* @param getTextKeyOverride
|
||||
* @param className - Additional classnames to add to the search container
|
||||
* @param disabled - If the search should be disabled
|
||||
* @returns
|
||||
*/
|
||||
export function useMultiSearch<OptionsType extends unknown[]>({
|
||||
availableOptions = [] as unknown as OptionsType,
|
||||
placeholder,
|
||||
getTextKeyOverride,
|
||||
className,
|
||||
disabled = false,
|
||||
}: {
|
||||
availableOptions?: OptionsType;
|
||||
placeholder?: string;
|
||||
getTextKeyOverride?: (node: OptionsType[0]) => string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}): [OptionsType, React.ReactNode] {
|
||||
const [filterValue, setFilterValue] = useState<string | null>(null);
|
||||
|
||||
// We conditionally show the search when there's more than 10 elements in the menu
|
||||
const shouldShowSearch = availableOptions.length > 10 && !disabled;
|
||||
|
||||
// Define the helper function used to enable search
|
||||
// If this is invalidly described, we will assume developer error - tf. avoid rendering
|
||||
const getTextKeyHelper = getTextKeyOverride || defaultGetStringKey;
|
||||
|
||||
// Iterate said options
|
||||
const filteredOptions = useMemo(() => {
|
||||
const currentFilter = filterValue ?? '';
|
||||
if (!shouldShowSearch || !currentFilter || !availableOptions.length) {
|
||||
// Don't render if available options aren't present, there's no filter active
|
||||
return availableOptions;
|
||||
}
|
||||
// Filter through the values, using a simple text-based search
|
||||
// nothing too fancy, but we can add a better search algo later if we need
|
||||
const upperFilterValue = currentFilter.toUpperCase();
|
||||
|
||||
return availableOptions.filter((value) =>
|
||||
getTextKeyHelper(value).includes(upperFilterValue),
|
||||
) as OptionsType;
|
||||
}, [availableOptions, getTextKeyHelper, filterValue, shouldShowSearch]);
|
||||
|
||||
const onSearchChange = useCallback(
|
||||
(nextFilterValue: string) => setFilterValue(nextFilterValue),
|
||||
[],
|
||||
);
|
||||
|
||||
const searchRender = shouldShowSearch ? (
|
||||
<MultiSearch
|
||||
value={filterValue}
|
||||
className={className}
|
||||
onChange={onSearchChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return [filteredOptions, searchRender];
|
||||
}
|
||||
146
packages/client/src/components/MultiSelect.tsx
Normal file
146
packages/client/src/components/MultiSelect.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectArrow,
|
||||
SelectItem,
|
||||
SelectItemCheck,
|
||||
SelectLabel,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
} from '@ariakit/react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MultiSelectProps<T extends string> {
|
||||
items: T[];
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
onSelectedValuesChange?: (values: T[]) => void;
|
||||
renderSelectedValues?: (values: T[], placeholder?: string) => React.ReactNode;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
labelClassName?: string;
|
||||
selectClassName?: string;
|
||||
selectIcon?: React.ReactNode;
|
||||
popoverClassName?: string;
|
||||
selectItemsClassName?: string;
|
||||
selectedValues: T[];
|
||||
setSelectedValues: (values: T[]) => void;
|
||||
renderItemContent?: (
|
||||
value: T,
|
||||
defaultContent: React.ReactNode,
|
||||
isSelected: boolean,
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
function defaultRender<T extends string>(values: T[], placeholder?: string) {
|
||||
if (values.length === 0) {
|
||||
return placeholder || 'Select...';
|
||||
}
|
||||
if (values.length === 1) {
|
||||
return values[0];
|
||||
}
|
||||
return `${values.length} items selected`;
|
||||
}
|
||||
|
||||
export default function MultiSelect<T extends string>({
|
||||
items,
|
||||
label,
|
||||
placeholder = 'Select...',
|
||||
onSelectedValuesChange,
|
||||
renderSelectedValues = defaultRender,
|
||||
className,
|
||||
selectIcon,
|
||||
itemClassName,
|
||||
labelClassName,
|
||||
selectClassName,
|
||||
popoverClassName,
|
||||
selectItemsClassName,
|
||||
selectedValues = [],
|
||||
setSelectedValues,
|
||||
renderItemContent,
|
||||
}: MultiSelectProps<T>) {
|
||||
const selectRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleValueChange = (values: T[]) => {
|
||||
setSelectedValues(values);
|
||||
if (onSelectedValuesChange) {
|
||||
onSelectedValuesChange(values);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<SelectProvider value={selectedValues} setValue={handleValueChange}>
|
||||
{label && (
|
||||
<SelectLabel className={cn('mb-1 block text-sm text-text-primary', labelClassName)}>
|
||||
{label}
|
||||
</SelectLabel>
|
||||
)}
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 rounded-xl px-3 py-2 text-sm',
|
||||
'bg-surface-tertiary text-text-primary shadow-sm hover:cursor-pointer hover:bg-surface-hover',
|
||||
'outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75',
|
||||
selectClassName,
|
||||
selectedValues.length > 0 && selectItemsClassName != null && selectItemsClassName,
|
||||
)}
|
||||
onChange={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectIcon && <span>{selectIcon as React.JSX.Element}</span>}
|
||||
<span className="mr-auto hidden truncate md:block">
|
||||
{renderSelectedValues(selectedValues, placeholder)}
|
||||
</span>
|
||||
<SelectArrow className="ml-1 hidden stroke-1 text-base opacity-75 md:block" />
|
||||
</Select>
|
||||
<SelectPopover
|
||||
gutter={4}
|
||||
sameWidth
|
||||
modal
|
||||
unmountOnHide
|
||||
finalFocus={selectRef}
|
||||
className={cn(
|
||||
'animate-popover z-50 flex max-h-[300px]',
|
||||
'flex-col overflow-auto overscroll-contain rounded-xl',
|
||||
'bg-surface-secondary px-1.5 py-1 text-text-primary shadow-lg',
|
||||
'border border-border-light',
|
||||
'outline-none',
|
||||
popoverClassName,
|
||||
)}
|
||||
>
|
||||
{items.map((value) => {
|
||||
const defaultContent = (
|
||||
<>
|
||||
<SelectItemCheck className="mr-0.5 text-primary" />
|
||||
<span className="truncate">{value}</span>
|
||||
</>
|
||||
);
|
||||
const isCurrentItemSelected = selectedValues.includes(value);
|
||||
return (
|
||||
<SelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-2 py-1.5 hover:cursor-pointer',
|
||||
'scroll-m-1 outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
'w-full min-w-0 text-sm',
|
||||
itemClassName,
|
||||
)}
|
||||
>
|
||||
{renderItemContent
|
||||
? (renderItemContent(
|
||||
value,
|
||||
defaultContent,
|
||||
isCurrentItemSelected,
|
||||
) as React.JSX.Element)
|
||||
: (defaultContent as React.JSX.Element)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
packages/client/src/components/OGDialogTemplate.tsx
Normal file
113
packages/client/src/components/OGDialogTemplate.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { forwardRef, ReactNode, Ref } from 'react';
|
||||
import {
|
||||
OGDialogTitle,
|
||||
OGDialogClose,
|
||||
OGDialogFooter,
|
||||
OGDialogHeader,
|
||||
OGDialogContent,
|
||||
OGDialogDescription,
|
||||
} from './OriginalDialog';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Button } from './Button';
|
||||
import { Spinner } from '~/svgs';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
type SelectionProps = {
|
||||
selectHandler?: () => void;
|
||||
selectClasses?: string;
|
||||
selectText?: string | ReactNode;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
type DialogTemplateProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
main?: ReactNode;
|
||||
buttons?: ReactNode;
|
||||
leftButtons?: ReactNode;
|
||||
selection?: SelectionProps;
|
||||
className?: string;
|
||||
overlayClassName?: string;
|
||||
headerClassName?: string;
|
||||
mainClassName?: string;
|
||||
footerClassName?: string;
|
||||
showCloseButton?: boolean;
|
||||
showCancelButton?: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDivElement>) => {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
title,
|
||||
main,
|
||||
buttons,
|
||||
selection,
|
||||
className,
|
||||
leftButtons,
|
||||
description = '',
|
||||
mainClassName,
|
||||
headerClassName,
|
||||
footerClassName,
|
||||
showCloseButton,
|
||||
overlayClassName,
|
||||
showCancelButton = true,
|
||||
} = props;
|
||||
const { selectHandler, selectClasses, selectText, isLoading } = selection || {};
|
||||
|
||||
const defaultSelect =
|
||||
'bg-gray-800 text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-gray-200';
|
||||
return (
|
||||
<OGDialogContent
|
||||
overlayClassName={overlayClassName}
|
||||
showCloseButton={showCloseButton}
|
||||
ref={ref}
|
||||
className={cn('w-11/12 border-none bg-background text-foreground', className ?? '')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<OGDialogHeader className={cn(headerClassName ?? '')}>
|
||||
<OGDialogTitle>{title}</OGDialogTitle>
|
||||
{description && (
|
||||
<OGDialogDescription className="items-center justify-center">
|
||||
{description}
|
||||
</OGDialogDescription>
|
||||
)}
|
||||
</OGDialogHeader>
|
||||
<div className={cn('px-0 py-2', mainClassName)}>{main != null ? main : null}</div>
|
||||
<OGDialogFooter className={footerClassName}>
|
||||
<div>
|
||||
{leftButtons != null ? (
|
||||
<div className="mt-3 flex h-auto gap-3 max-sm:w-full max-sm:flex-col sm:mt-0 sm:flex-row">
|
||||
{leftButtons}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex h-auto gap-3 max-sm:w-full max-sm:flex-col sm:flex-row">
|
||||
{showCancelButton && (
|
||||
<OGDialogClose asChild>
|
||||
<Button variant="outline">{localize('com_ui_cancel')}</Button>
|
||||
</OGDialogClose>
|
||||
)}
|
||||
{buttons != null ? buttons : null}
|
||||
{selection ? (
|
||||
<OGDialogClose
|
||||
onClick={selectHandler}
|
||||
disabled={isLoading}
|
||||
className={`${
|
||||
selectClasses ?? defaultSelect
|
||||
} flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm disabled:opacity-80 max-sm:order-first max-sm:w-full sm:order-none`}
|
||||
>
|
||||
{isLoading === true ? (
|
||||
<Spinner className="size-4 text-white" />
|
||||
) : (
|
||||
(selectText as React.JSX.Element)
|
||||
)}
|
||||
</OGDialogClose>
|
||||
) : null}
|
||||
</div>
|
||||
</OGDialogFooter>
|
||||
</OGDialogContent>
|
||||
);
|
||||
});
|
||||
|
||||
export default OGDialogTemplate;
|
||||
141
packages/client/src/components/OriginalDialog.tsx
Normal file
141
packages/client/src/components/OriginalDialog.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface OGDialogProps extends DialogPrimitive.DialogProps {
|
||||
triggerRef?: React.RefObject<HTMLButtonElement | HTMLInputElement | null>;
|
||||
triggerRefs?: React.RefObject<HTMLButtonElement | HTMLInputElement | null>[];
|
||||
}
|
||||
|
||||
const Dialog = React.forwardRef<HTMLDivElement, OGDialogProps>(
|
||||
({ children, triggerRef, triggerRefs, onOpenChange, ...props }, _ref) => {
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open && triggerRef?.current) {
|
||||
setTimeout(() => {
|
||||
triggerRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
if (triggerRefs?.length) {
|
||||
triggerRefs.forEach((ref) => {
|
||||
if (ref?.current) {
|
||||
setTimeout(() => {
|
||||
ref.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
onOpenChange?.(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root {...props} onOpenChange={handleOpenChange}>
|
||||
{children}
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
export const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
disableScroll?: boolean;
|
||||
overlayClassName?: string;
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(({ className, overlayClassName, showCloseButton = true, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'max-w-11/12 fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl bg-background p-6 text-text-primary shadow-lg duration-200 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%]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-ring-primary ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
{/* eslint-disable-next-line i18next/no-literal-string */}
|
||||
<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-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog as OGDialog,
|
||||
DialogPortal as OGDialogPortal,
|
||||
DialogOverlay as OGDialogOverlay,
|
||||
DialogClose as OGDialogClose,
|
||||
DialogTrigger as OGDialogTrigger,
|
||||
DialogContent as OGDialogContent,
|
||||
DialogHeader as OGDialogHeader,
|
||||
DialogFooter as OGDialogFooter,
|
||||
DialogTitle as OGDialogTitle,
|
||||
DialogDescription as OGDialogDescription,
|
||||
};
|
||||
105
packages/client/src/components/Pagination.tsx
Normal file
105
packages/client/src/components/Pagination.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import { ButtonProps, buttonVariants } from './Button';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Pagination.displayName = 'Pagination';
|
||||
|
||||
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ul ref={ref} className={cn('flex flex-row items-center gap-1', className)} {...props} />
|
||||
),
|
||||
);
|
||||
PaginationContent.displayName = 'PaginationContent';
|
||||
|
||||
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<'li'>>(
|
||||
({ className, ...props }, ref) => <li ref={ref} className={cn('', className)} {...props} />,
|
||||
);
|
||||
PaginationItem.displayName = 'PaginationItem';
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<ButtonProps, 'size'> &
|
||||
React.ComponentProps<'a'>;
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive = false,
|
||||
size = 'icon',
|
||||
children,
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children || <span className="sr-only">Page link</span>}
|
||||
</a>
|
||||
);
|
||||
PaginationLink.displayName = 'PaginationLink';
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn('gap-1 pl-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationPrevious.displayName = 'PaginationPrevious';
|
||||
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn('gap-1 pr-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = 'PaginationNext';
|
||||
|
||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
PaginationEllipsis.displayName = 'PaginationEllipsis';
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
};
|
||||
380
packages/client/src/components/PixelCard.tsx
Normal file
380
packages/client/src/components/PixelCard.tsx
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
class Pixel {
|
||||
width: number;
|
||||
height: number;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
speed: number;
|
||||
size: number;
|
||||
sizeStep: number;
|
||||
minSize: number;
|
||||
maxSizeInteger: number;
|
||||
maxSize: number;
|
||||
delay: number;
|
||||
counter: number;
|
||||
counterStep: number;
|
||||
isIdle: boolean;
|
||||
isReverse: boolean;
|
||||
isShimmer: boolean;
|
||||
activationThreshold: number;
|
||||
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
color: string,
|
||||
speed: number,
|
||||
delay: number,
|
||||
activationThreshold: number,
|
||||
) {
|
||||
this.width = canvas.width;
|
||||
this.height = canvas.height;
|
||||
this.ctx = context;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.color = color;
|
||||
this.speed = this.random(0.1, 0.9) * speed;
|
||||
this.size = 0;
|
||||
this.sizeStep = Math.random() * 0.4;
|
||||
this.minSize = 0.5;
|
||||
this.maxSizeInteger = 2;
|
||||
this.maxSize = this.random(this.minSize, this.maxSizeInteger);
|
||||
this.delay = delay;
|
||||
this.counter = 0;
|
||||
this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01;
|
||||
this.isIdle = false;
|
||||
this.isReverse = false;
|
||||
this.isShimmer = false;
|
||||
this.activationThreshold = activationThreshold;
|
||||
}
|
||||
|
||||
private random(min: number, max: number) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
private draw() {
|
||||
const offset = this.maxSizeInteger * 0.5 - this.size * 0.5;
|
||||
this.ctx.fillStyle = this.color;
|
||||
this.ctx.fillRect(this.x + offset, this.y + offset, this.size, this.size);
|
||||
}
|
||||
|
||||
appear() {
|
||||
this.isIdle = false;
|
||||
if (this.counter <= this.delay) {
|
||||
this.counter += this.counterStep;
|
||||
return;
|
||||
}
|
||||
if (this.size >= this.maxSize) {
|
||||
this.isShimmer = true;
|
||||
}
|
||||
if (this.isShimmer) {
|
||||
this.shimmer();
|
||||
} else {
|
||||
this.size += this.sizeStep;
|
||||
}
|
||||
this.draw();
|
||||
}
|
||||
|
||||
appearWithProgress(progress: number) {
|
||||
const diff = progress - this.activationThreshold;
|
||||
if (diff <= 0) {
|
||||
this.isIdle = true;
|
||||
return;
|
||||
}
|
||||
if (this.counter <= this.delay) {
|
||||
this.counter += this.counterStep;
|
||||
this.isIdle = false;
|
||||
return;
|
||||
}
|
||||
if (this.size >= this.maxSize) {
|
||||
this.isShimmer = true;
|
||||
}
|
||||
if (this.isShimmer) {
|
||||
this.shimmer();
|
||||
} else {
|
||||
this.size += this.sizeStep;
|
||||
}
|
||||
this.isIdle = false;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
disappear() {
|
||||
this.isShimmer = false;
|
||||
this.counter = 0;
|
||||
if (this.size <= 0) {
|
||||
this.isIdle = true;
|
||||
return;
|
||||
}
|
||||
this.size -= 0.1;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
private shimmer() {
|
||||
if (this.size >= this.maxSize) {
|
||||
this.isReverse = true;
|
||||
} else if (this.size <= this.minSize) {
|
||||
this.isReverse = false;
|
||||
}
|
||||
this.size += this.isReverse ? -this.speed : this.speed;
|
||||
}
|
||||
}
|
||||
|
||||
const getEffectiveSpeed = (value: number, reducedMotion: boolean) => {
|
||||
const parsed = parseInt(String(value), 10);
|
||||
const throttle = 0.001;
|
||||
if (parsed <= 0 || reducedMotion) {
|
||||
return 0;
|
||||
}
|
||||
if (parsed >= 100) {
|
||||
return 100 * throttle;
|
||||
}
|
||||
return parsed * throttle;
|
||||
};
|
||||
|
||||
const clamp = (n: number, min = 0, max = 1) => Math.min(Math.max(n, min), max);
|
||||
|
||||
const VARIANTS = {
|
||||
default: { gap: 5, speed: 35, colors: '#f8fafc,#f1f5f9,#cbd5e1', noFocus: false },
|
||||
blue: { gap: 10, speed: 25, colors: '#e0f2fe,#7dd3fc,#0ea5e9', noFocus: false },
|
||||
yellow: { gap: 3, speed: 20, colors: '#fef08a,#fde047,#eab308', noFocus: false },
|
||||
pink: { gap: 6, speed: 80, colors: '#fecdd3,#fda4af,#e11d48', noFocus: true },
|
||||
} as const;
|
||||
|
||||
interface PixelCardProps {
|
||||
variant?: keyof typeof VARIANTS;
|
||||
gap?: number;
|
||||
speed?: number;
|
||||
colors?: string;
|
||||
noFocus?: boolean;
|
||||
className?: string;
|
||||
progress?: number;
|
||||
randomness?: number;
|
||||
width?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export default function PixelCard({
|
||||
variant = 'default',
|
||||
gap,
|
||||
speed,
|
||||
colors,
|
||||
noFocus,
|
||||
className = '',
|
||||
progress,
|
||||
randomness = 0.3,
|
||||
width,
|
||||
height,
|
||||
}: PixelCardProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pixelsRef = useRef<Pixel[]>([]);
|
||||
const animationRef = useRef<number | undefined>(undefined);
|
||||
const timePrevRef = useRef(performance.now());
|
||||
const progressRef = useRef<number | undefined>(progress);
|
||||
const reducedMotion = useRef(
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
).current;
|
||||
|
||||
const cfg = VARIANTS[variant];
|
||||
const g = gap ?? cfg.gap;
|
||||
const s = speed ?? cfg.speed;
|
||||
const palette = colors ?? cfg.colors;
|
||||
const disableFocus = noFocus ?? cfg.noFocus;
|
||||
|
||||
const updateCanvasOpacity = useCallback(() => {
|
||||
if (!canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
if (progressRef.current === undefined) {
|
||||
canvasRef.current.style.opacity = '1';
|
||||
return;
|
||||
}
|
||||
const fadeStart = 0.9;
|
||||
const alpha =
|
||||
progressRef.current >= fadeStart ? 1 - (progressRef.current - fadeStart) / 0.1 : 1;
|
||||
canvasRef.current.style.opacity = String(clamp(alpha));
|
||||
}, []);
|
||||
|
||||
const animate = useCallback(
|
||||
(method: keyof Pixel) => {
|
||||
animationRef.current = requestAnimationFrame(() => animate(method));
|
||||
|
||||
const now = performance.now();
|
||||
const elapsed = now - timePrevRef.current;
|
||||
if (elapsed < 1000 / 60) {
|
||||
return;
|
||||
}
|
||||
timePrevRef.current = now - (elapsed % (1000 / 60));
|
||||
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
if (!ctx || !canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
|
||||
|
||||
let idle = true;
|
||||
for (const p of pixelsRef.current) {
|
||||
if (method === 'appearWithProgress') {
|
||||
if (progressRef.current !== undefined) {
|
||||
p.appearWithProgress(progressRef.current);
|
||||
} else {
|
||||
p.isIdle = true;
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore dynamic dispatch
|
||||
p[method]();
|
||||
}
|
||||
if (!p.isIdle) {
|
||||
idle = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateCanvasOpacity();
|
||||
if (idle) {
|
||||
cancelAnimationFrame(animationRef.current!);
|
||||
}
|
||||
},
|
||||
[updateCanvasOpacity],
|
||||
);
|
||||
|
||||
const startAnim = useCallback(
|
||||
(m: keyof Pixel) => {
|
||||
cancelAnimationFrame(animationRef.current!);
|
||||
animationRef.current = requestAnimationFrame(() => animate(m));
|
||||
},
|
||||
[animate],
|
||||
);
|
||||
|
||||
const initPixels = useCallback(() => {
|
||||
if (!containerRef.current || !canvasRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { width: cw, height: ch } = containerRef.current.getBoundingClientRect();
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
canvasRef.current.width = Math.floor(cw);
|
||||
canvasRef.current.height = Math.floor(ch);
|
||||
|
||||
const cols = palette.split(',');
|
||||
const px: Pixel[] = [];
|
||||
|
||||
const cx = cw / 2;
|
||||
const cy = ch / 2;
|
||||
const maxDist = Math.hypot(cx, cy);
|
||||
|
||||
for (let x = 0; x < cw; x += g) {
|
||||
for (let y = 0; y < ch; y += g) {
|
||||
const color = cols[Math.floor(Math.random() * cols.length)];
|
||||
const distNorm = Math.hypot(x - cx, y - cy) / maxDist;
|
||||
const threshold = clamp(distNorm * (1 - randomness) + Math.random() * randomness);
|
||||
const delay = reducedMotion ? 0 : distNorm * maxDist;
|
||||
if (!ctx) {
|
||||
continue;
|
||||
}
|
||||
px.push(
|
||||
new Pixel(
|
||||
canvasRef.current,
|
||||
ctx,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
getEffectiveSpeed(s, reducedMotion),
|
||||
delay,
|
||||
threshold,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
pixelsRef.current = px;
|
||||
|
||||
if (progressRef.current !== undefined) {
|
||||
startAnim('appearWithProgress');
|
||||
}
|
||||
}, [g, palette, s, randomness, reducedMotion, startAnim]);
|
||||
|
||||
useEffect(() => {
|
||||
progressRef.current = progress;
|
||||
if (progress !== undefined) {
|
||||
startAnim('appearWithProgress');
|
||||
}
|
||||
}, [progress, startAnim]);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress === undefined) {
|
||||
cancelAnimationFrame(animationRef.current!);
|
||||
}
|
||||
}, [progress]);
|
||||
|
||||
useEffect(() => {
|
||||
initPixels();
|
||||
const obs = new ResizeObserver(initPixels);
|
||||
if (containerRef.current) {
|
||||
obs.observe(containerRef.current);
|
||||
}
|
||||
return () => {
|
||||
obs.disconnect();
|
||||
cancelAnimationFrame(animationRef.current!);
|
||||
};
|
||||
}, [initPixels]);
|
||||
|
||||
const hoverIn = () => progressRef.current === undefined && startAnim('appear');
|
||||
const hoverOut = () => progressRef.current === undefined && startAnim('disappear');
|
||||
const focusIn: React.FocusEventHandler<HTMLDivElement> = (e) => {
|
||||
if (
|
||||
!disableFocus &&
|
||||
!e.currentTarget.contains(e.relatedTarget) &&
|
||||
progressRef.current === undefined
|
||||
) {
|
||||
startAnim('appear');
|
||||
}
|
||||
};
|
||||
const focusOut: React.FocusEventHandler<HTMLDivElement> = (e) => {
|
||||
if (
|
||||
!disableFocus &&
|
||||
!e.currentTarget.contains(e.relatedTarget) &&
|
||||
progressRef.current === undefined
|
||||
) {
|
||||
startAnim('disappear');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: width || '100%',
|
||||
height: height || '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative isolate grid select-none place-items-center overflow-hidden rounded-lg border border-border-light shadow-md transition-colors duration-200 ease-in-out',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transitionTimingFunction: 'cubic-bezier(0.5, 1, 0.89, 1)',
|
||||
}}
|
||||
onMouseEnter={hoverIn}
|
||||
onMouseLeave={hoverOut}
|
||||
onFocus={disableFocus ? undefined : focusIn}
|
||||
onBlur={disableFocus ? undefined : focusOut}
|
||||
tabIndex={disableFocus ? -1 : 0}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none absolute inset-0 block"
|
||||
width={width && width !== 'auto' ? parseInt(String(width)) : undefined}
|
||||
height={height && height !== 'auto' ? parseInt(String(height)) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
packages/client/src/components/Progress.tsx
Normal file
22
packages/client/src/components/Progress.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
16
packages/client/src/components/QuestionMark.tsx
Normal file
16
packages/client/src/components/QuestionMark.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { cn } from '~/utils';
|
||||
|
||||
export const QuestionMark = ({ className = '' }) => {
|
||||
return (
|
||||
<span>
|
||||
<div
|
||||
className={cn(
|
||||
'border-token-border-medium text-token-text-tertiary ml-2 flex h-3.5 w-3.5 cursor-default items-center justify-center rounded-full border text-[0.5rem] font-medium leading-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
62
packages/client/src/components/Resizable.tsx
Normal file
62
packages/client/src/components/Resizable.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { GripVertical } from 'lucide-react';
|
||||
import * as ResizablePrimitive from 'react-resizable-panels';
|
||||
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className = '',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel;
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className = '',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
|
||||
const ResizableHandleAlt = ({
|
||||
withHandle,
|
||||
className = '',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
'group relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="invisible z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border group-hover:visible group-active:visible">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle, ResizableHandleAlt };
|
||||
163
packages/client/src/components/Select.tsx
Normal file
163
packages/client/src/components/Select.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
|
||||
|
||||
import { cn } from '~/utils';
|
||||
|
||||
// @ts-ignore - Radix UI type conflicts with React types
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
// @ts-ignore - Radix UI type conflicts with React types
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
// @ts-ignore - Radix UI type conflicts with React types
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className = '', children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-gray-200 border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 [&>span]:line-clamp-1',
|
||||
'rounded-lg hover:bg-gray-100/50 dark:hover:bg-gray-700',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<CaretSortIcon 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 dark:text-white',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</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 dark:text-white',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</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 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 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 dark:border-gray-600',
|
||||
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(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'rounded-lg hover:bg-gray-100/50 dark:hover:bg-gray-700',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon 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('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
282
packages/client/src/components/SelectDropDown.tsx
Normal file
282
packages/client/src/components/SelectDropDown.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Label,
|
||||
Listbox,
|
||||
Transition,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from '@headlessui/react';
|
||||
import type { Option, OptionWithIcon, DropdownValueSetter } from '~/common';
|
||||
import { useMultiSearch } from './MultiSearch';
|
||||
import { CheckMark } from '~/svgs';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type SelectDropDownProps = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
value: string | null | Option | OptionWithIcon;
|
||||
setValue: DropdownValueSetter | ((value: string) => void);
|
||||
tabIndex?: number;
|
||||
availableValues?: string[] | Option[] | OptionWithIcon[];
|
||||
emptyTitle?: boolean;
|
||||
showAbove?: boolean;
|
||||
showLabel?: boolean;
|
||||
iconSide?: 'left' | 'right';
|
||||
optionIconSide?: 'left' | 'right';
|
||||
renderOption?: () => React.ReactNode;
|
||||
containerClassName?: string;
|
||||
currentValueClass?: string;
|
||||
optionsListClass?: string;
|
||||
optionsClass?: string;
|
||||
subContainerClassName?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
searchClassName?: string;
|
||||
searchPlaceholder?: string;
|
||||
showOptionIcon?: boolean;
|
||||
};
|
||||
|
||||
function getOptionText(option: string | Option | OptionWithIcon): string {
|
||||
if (typeof option === 'string') {
|
||||
return option;
|
||||
}
|
||||
if ('label' in option) {
|
||||
return option.label ?? '';
|
||||
}
|
||||
if ('value' in option) {
|
||||
return (option.value ?? '') + '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function SelectDropDown({
|
||||
title: _title,
|
||||
value,
|
||||
disabled,
|
||||
setValue,
|
||||
availableValues,
|
||||
showAbove = false,
|
||||
showLabel = true,
|
||||
emptyTitle = false,
|
||||
iconSide = 'right',
|
||||
optionIconSide = 'left',
|
||||
placeholder,
|
||||
containerClassName,
|
||||
optionsListClass,
|
||||
optionsClass,
|
||||
currentValueClass,
|
||||
subContainerClassName,
|
||||
className,
|
||||
renderOption,
|
||||
searchClassName,
|
||||
searchPlaceholder,
|
||||
showOptionIcon = false,
|
||||
}: SelectDropDownProps) {
|
||||
const transitionProps = { className: 'top-full mt-3' };
|
||||
if (showAbove) {
|
||||
transitionProps.className = 'bottom-full mb-3';
|
||||
}
|
||||
|
||||
let title = _title;
|
||||
if (emptyTitle) {
|
||||
title = '';
|
||||
}
|
||||
|
||||
const values = availableValues ?? [];
|
||||
|
||||
// Enable searchable select if enough items are provided.
|
||||
const [filteredValues, searchRender] = useMultiSearch<string[] | Option[]>({
|
||||
availableOptions: values,
|
||||
placeholder: searchPlaceholder,
|
||||
getTextKeyOverride: (option) => getOptionText(option).toUpperCase(),
|
||||
className: searchClassName,
|
||||
disabled,
|
||||
});
|
||||
const hasSearchRender = searchRender != null;
|
||||
const options = hasSearchRender ? filteredValues : values;
|
||||
const renderIcon = showOptionIcon && value != null && (value as OptionWithIcon).icon != null;
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center gap-2', containerClassName ?? '')}>
|
||||
<div className={cn('relative w-full', subContainerClassName ?? '')}>
|
||||
<Listbox value={value} onChange={setValue} disabled={disabled}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<ListboxButton
|
||||
ref={buttonRef}
|
||||
data-testid="select-dropdown-button"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (!open && buttonRef.current) {
|
||||
buttonRef.current.click();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:bg-white dark:border-gray-600 dark:bg-gray-700 sm:text-sm',
|
||||
className ?? '',
|
||||
)}
|
||||
>
|
||||
{showLabel && (
|
||||
<Label
|
||||
className="block text-xs text-gray-700 dark:text-gray-500"
|
||||
id="headlessui-listbox-label-:r1:"
|
||||
data-headlessui-state=""
|
||||
>
|
||||
{title}
|
||||
</Label>
|
||||
)}
|
||||
<span className="inline-flex w-full truncate">
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 items-center gap-1 truncate text-sm text-gray-800 dark:text-white',
|
||||
!showLabel ? 'text-xs' : '',
|
||||
currentValueClass ?? '',
|
||||
)}
|
||||
>
|
||||
{!showLabel && !emptyTitle && (
|
||||
<span className="text-xs text-gray-700 dark:text-gray-500">{title}:</span>
|
||||
)}
|
||||
{renderIcon && optionIconSide !== 'right' && (
|
||||
<span className="icon-md flex items-center">
|
||||
{(value as OptionWithIcon).icon}
|
||||
</span>
|
||||
)}
|
||||
{renderIcon && (
|
||||
<span className="icon-md absolute right-0 mr-8 flex items-center">
|
||||
{(value as OptionWithIcon).icon}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
if (!value) {
|
||||
return <span className="text-text-secondary">{placeholder}</span>;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return value.label ?? '';
|
||||
}
|
||||
return value;
|
||||
})()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4 text-gray-400"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={showAbove ? { transform: 'scaleY(-1)' } : {}}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
show={open}
|
||||
as="div"
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
{...transitionProps}
|
||||
>
|
||||
<ListboxOptions
|
||||
className={cn(
|
||||
'absolute z-10 mt-2 max-h-60 w-full overflow-auto rounded border bg-white text-xs ring-black/10 dark:border-gray-600 dark:bg-gray-700 dark:ring-white/20 md:w-[100%]',
|
||||
optionsListClass ?? '',
|
||||
)}
|
||||
>
|
||||
{renderOption && (
|
||||
<ListboxOption
|
||||
key={'listbox-render-option'}
|
||||
value={null}
|
||||
className={cn(
|
||||
'group relative flex h-[42px] cursor-pointer select-none items-center overflow-hidden pl-3 pr-9 text-gray-800 hover:bg-gray-20 dark:text-white dark:hover:bg-gray-700',
|
||||
optionsClass ?? '',
|
||||
)}
|
||||
>
|
||||
{renderOption() as React.JSX.Element}
|
||||
</ListboxOption>
|
||||
)}
|
||||
{searchRender as React.JSX.Element}
|
||||
{options.map((option: string | Option, i: number) => {
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
const currentLabel =
|
||||
typeof option === 'string' ? option : (option.label ?? option.value ?? '');
|
||||
const currentValue = typeof option === 'string' ? option : (option.value ?? '');
|
||||
const currentIcon =
|
||||
typeof option === 'string'
|
||||
? null
|
||||
: ((option.icon as React.ReactNode) ?? null);
|
||||
let activeValue: string | number | null | Option = value;
|
||||
if (typeof activeValue !== 'string') {
|
||||
activeValue = activeValue?.value ?? '';
|
||||
}
|
||||
return (
|
||||
<ListboxOption
|
||||
key={i}
|
||||
value={option}
|
||||
className={({ active }) =>
|
||||
cn(
|
||||
'group relative flex h-[42px] cursor-pointer select-none items-center overflow-hidden pl-3 pr-9 text-gray-800 hover:bg-gray-20 dark:text-white dark:hover:bg-gray-600',
|
||||
active ? 'bg-surface-active text-text-primary' : '',
|
||||
optionsClass ?? '',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 items-center gap-1 text-gray-800 dark:text-gray-200',
|
||||
option === value ? 'font-semibold' : '',
|
||||
iconSide === 'left' ? 'ml-4' : '',
|
||||
)}
|
||||
>
|
||||
{currentIcon != null && (
|
||||
<span
|
||||
className={cn(
|
||||
'mr-1',
|
||||
optionIconSide === 'right' ? 'absolute right-0 pr-2' : '',
|
||||
)}
|
||||
>
|
||||
{currentIcon}
|
||||
</span>
|
||||
)}
|
||||
{currentLabel}
|
||||
</span>
|
||||
{currentValue === activeValue && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-y-0 flex items-center text-gray-800 dark:text-gray-200',
|
||||
iconSide === 'left' ? 'left-0 pl-2' : 'right-0 pr-3',
|
||||
)}
|
||||
>
|
||||
<CheckMark />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</ListboxOption>
|
||||
);
|
||||
})}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectDropDown;
|
||||
28
packages/client/src/components/Separator.tsx
Normal file
28
packages/client/src/components/Separator.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className = '', orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
{...props}
|
||||
{...{
|
||||
decorative,
|
||||
orientation,
|
||||
className: cn(
|
||||
'shrink-0 bg-border-light',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
15
packages/client/src/components/Skeleton.tsx
Normal file
15
packages/client/src/components/Skeleton.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { cn } from '~/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-pulse rounded-md bg-surface-tertiary opacity-50 dark:opacity-25',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
38
packages/client/src/components/Slider.tsx
Normal file
38
packages/client/src/components/Slider.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import * as React from 'react';
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
|
||||
className?: string;
|
||||
onDoubleClick?: () => void;
|
||||
}
|
||||
>(({ className, onDoubleClick, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
{...props}
|
||||
{...{
|
||||
className: cn(
|
||||
'relative flex w-full cursor-pointer touch-none select-none items-center',
|
||||
className,
|
||||
),
|
||||
onDoubleClick,
|
||||
}}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
{...{ className: 'relative h-2 w-full grow overflow-hidden rounded-full bg-secondary' }}
|
||||
>
|
||||
<SliderPrimitive.Range {...{ className: 'absolute h-full bg-primary' }} />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
{...{
|
||||
className:
|
||||
'block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
}}
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
38
packages/client/src/components/SplitText.spec.tsx
Normal file
38
packages/client/src/components/SplitText.spec.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { render } from '@testing-library/react';
|
||||
import SplitText from './SplitText';
|
||||
|
||||
// Mock IntersectionObserver
|
||||
class MockIntersectionObserver {
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver,
|
||||
});
|
||||
|
||||
describe('SplitText', () => {
|
||||
it('renders emojis correctly', () => {
|
||||
const emojis = ['🚧', '❤️🔥', '💜', '🦎', '❌', '✅', '⚠️'];
|
||||
const originalText = emojis.join('');
|
||||
|
||||
const { container } = render(<SplitText text={originalText} />);
|
||||
const textSpans = container.querySelectorAll('p > span > span.inline-block');
|
||||
|
||||
// Reconstruct the text by joining all span contents
|
||||
const reconstructedText = Array.from(textSpans)
|
||||
.map((span) => span.textContent)
|
||||
.join('')
|
||||
.trim();
|
||||
// Compare the reconstructed text with the original
|
||||
expect(reconstructedText).toBe(originalText);
|
||||
|
||||
// Check the first character specifically as the reconstructed text could hide issues
|
||||
for (let i = 0; i < emojis.length; i++) {
|
||||
expect(Array.from(textSpans)[i].textContent).toBe(emojis[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
171
packages/client/src/components/SplitText.tsx
Normal file
171
packages/client/src/components/SplitText.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { useSprings, animated, SpringConfig } from '@react-spring/web';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface SegmenterOptions {
|
||||
granularity?: 'grapheme' | 'word' | 'sentence';
|
||||
localeMatcher?: 'lookup' | 'best fit';
|
||||
}
|
||||
|
||||
interface SegmentData {
|
||||
segment: string;
|
||||
index: number;
|
||||
input: string;
|
||||
isWordLike?: boolean;
|
||||
}
|
||||
|
||||
interface Segments {
|
||||
[Symbol.iterator](): IterableIterator<SegmentData>;
|
||||
}
|
||||
|
||||
interface IntlSegmenter {
|
||||
segment(input: string): Segments;
|
||||
}
|
||||
|
||||
interface IntlSegmenterConstructor {
|
||||
new (locales?: string | string[], options?: SegmenterOptions): IntlSegmenter;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Intl {
|
||||
Segmenter: IntlSegmenterConstructor;
|
||||
}
|
||||
}
|
||||
|
||||
interface SplitTextProps {
|
||||
text?: string;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
animationFrom?: { opacity: number; transform: string };
|
||||
animationTo?: { opacity: number; transform: string };
|
||||
easing?: SpringConfig['easing'];
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
textAlign?: 'left' | 'right' | 'center' | 'justify' | 'start' | 'end';
|
||||
onLetterAnimationComplete?: () => void;
|
||||
onLineCountChange?: (lineCount: number) => void;
|
||||
}
|
||||
|
||||
const splitGraphemes = (text: string): string[] => {
|
||||
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
|
||||
const segmenter = new (Intl as typeof Intl & { Segmenter: IntlSegmenterConstructor }).Segmenter(
|
||||
'en',
|
||||
{ granularity: 'grapheme' },
|
||||
);
|
||||
const segments = segmenter.segment(text);
|
||||
return Array.from(segments).map((s: SegmentData) => s.segment);
|
||||
} else {
|
||||
return [...text];
|
||||
}
|
||||
};
|
||||
|
||||
const SplitText: React.FC<SplitTextProps> = ({
|
||||
text = '',
|
||||
className = '',
|
||||
delay = 100,
|
||||
animationFrom = { opacity: 0, transform: 'translate3d(0,40px,0)' },
|
||||
animationTo = { opacity: 1, transform: 'translate3d(0,0,0)' },
|
||||
easing = (t: number) => t,
|
||||
threshold = 0.1,
|
||||
rootMargin = '-100px',
|
||||
textAlign = 'center',
|
||||
onLetterAnimationComplete,
|
||||
onLineCountChange,
|
||||
}) => {
|
||||
const words = text.split(' ').map(splitGraphemes);
|
||||
const letters = words.flat();
|
||||
const [inView, setInView] = useState(false);
|
||||
const ref = useRef<HTMLParagraphElement>(null);
|
||||
const animatedCount = useRef(0);
|
||||
|
||||
const [springs] = useSprings(
|
||||
letters.length,
|
||||
(i) => ({
|
||||
from: animationFrom,
|
||||
to: inView
|
||||
? async (next) => {
|
||||
await next(animationTo);
|
||||
animatedCount.current += 1;
|
||||
if (animatedCount.current === letters.length && onLetterAnimationComplete) {
|
||||
onLetterAnimationComplete();
|
||||
}
|
||||
}
|
||||
: animationFrom,
|
||||
delay: i * delay,
|
||||
config: { easing },
|
||||
}),
|
||||
[inView, text, delay, animationFrom, animationTo, easing, onLetterAnimationComplete],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setInView(true);
|
||||
if (ref.current) {
|
||||
observer.unobserve(ref.current);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold, rootMargin },
|
||||
);
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [threshold, rootMargin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current && inView) {
|
||||
const element = ref.current;
|
||||
setTimeout(() => {
|
||||
const lineHeight =
|
||||
parseInt(getComputedStyle(element).lineHeight) ||
|
||||
parseInt(getComputedStyle(element).fontSize) * 1.2;
|
||||
const height = element.offsetHeight;
|
||||
const lines = Math.round(height / lineHeight);
|
||||
|
||||
if (onLineCountChange) {
|
||||
onLineCountChange(lines);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [inView, text, onLineCountChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="sr-only">{text}</span>
|
||||
<p
|
||||
ref={ref}
|
||||
className={`split-parent inline overflow-hidden ${className}`}
|
||||
style={{ textAlign, whiteSpace: 'normal', wordWrap: 'break-word' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{words.map((word, wordIndex) => (
|
||||
<span key={wordIndex} style={{ display: 'inline-block', whiteSpace: 'nowrap' }}>
|
||||
{word.map((letter, letterIndex) => {
|
||||
const index =
|
||||
words.slice(0, wordIndex).reduce((acc, w) => acc + w.length, 0) + letterIndex;
|
||||
|
||||
return (
|
||||
<animated.span
|
||||
key={index}
|
||||
style={springs[index]}
|
||||
className="inline-block transform transition-opacity will-change-transform"
|
||||
>
|
||||
{letter}
|
||||
</animated.span>
|
||||
);
|
||||
})}
|
||||
{wordIndex < words.length - 1 && (
|
||||
<span style={{ display: 'inline-block', width: '0.3em' }}> </span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SplitText;
|
||||
26
packages/client/src/components/Switch.tsx
Normal file
26
packages/client/src/components/Switch.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-unchecked',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
90
packages/client/src/components/Table.tsx
Normal file
90
packages/client/src/components/Table.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import * as React from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b border-border-light transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
45
packages/client/src/components/Tabs.tsx
Normal file
45
packages/client/src/components/Tabs.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md bg-surface-primary',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
'inline-flex min-w-[100px] items-center justify-center rounded-[0.185rem] px-3 py-1.5 text-sm font-medium text-gray-700 transition-all disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-800 data-[state=active]:shadow-sm dark:data-[state=active]:bg-gray-700 dark:data-[state=active]:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<TabsPrimitive.Content className={cn('mt-2 rounded-md p-6', className)} {...props} ref={ref} />
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
50
packages/client/src/components/Tag.tsx
Normal file
50
packages/client/src/components/Tag.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type TagProps = React.ComponentPropsWithoutRef<'div'> & {
|
||||
label: string;
|
||||
labelClassName?: string;
|
||||
CancelButton?: React.ReactNode;
|
||||
LabelNode?: React.ReactNode;
|
||||
onRemove?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
};
|
||||
|
||||
const TagPrimitiveRoot = React.forwardRef<HTMLDivElement, TagProps>(
|
||||
(
|
||||
{ CancelButton, LabelNode, label, onRemove, className = '', labelClassName = '', ...props },
|
||||
ref,
|
||||
) => (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
'flex max-h-8 items-center overflow-y-hidden rounded-3xl border-2 border-green-600 bg-green-600/20 text-xs text-green-600 dark:text-white',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn('ml-1 whitespace-pre-wrap px-2 py-1', labelClassName)}>
|
||||
{LabelNode ? <>{LabelNode} </> : null}
|
||||
{label}
|
||||
</div>
|
||||
{CancelButton
|
||||
? CancelButton
|
||||
: onRemove && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(e);
|
||||
}}
|
||||
className="rounded-full bg-green-600/50"
|
||||
aria-label={`Remove ${label}`}
|
||||
>
|
||||
<X className="m-[1.5px] p-1" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
TagPrimitiveRoot.displayName = 'Tag';
|
||||
|
||||
export const Tag = React.memo(TagPrimitiveRoot);
|
||||
24
packages/client/src/components/Textarea.tsx
Normal file
24
packages/client/src/components/Textarea.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/* eslint-disable */
|
||||
import * as React from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className = '', ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex h-20 w-full resize-none rounded-md border border-gray-300 bg-transparent px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-50 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
14
packages/client/src/components/TextareaAutosize.tsx
Normal file
14
packages/client/src/components/TextareaAutosize.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { forwardRef, useLayoutEffect, useState } from 'react';
|
||||
import ReactTextareaAutosize from 'react-textarea-autosize';
|
||||
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
|
||||
import { chatDirectionAtom } from '~/store';
|
||||
|
||||
export const TextareaAutosize = forwardRef<HTMLTextAreaElement, TextareaAutosizeProps>(
|
||||
(props, ref) => {
|
||||
const [, setIsRerendered] = useState(false);
|
||||
const chatDirection = useAtomValue(chatDirectionAtom).toLowerCase();
|
||||
useLayoutEffect(() => setIsRerendered(true), []);
|
||||
return <ReactTextareaAutosize dir={chatDirection} {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
105
packages/client/src/components/ThemeSelector.tsx
Normal file
105
packages/client/src/components/ThemeSelector.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useContext, useCallback, useEffect, useState } from 'react';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
import { ThemeContext } from '../theme';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
lastThemeChange?: number;
|
||||
}
|
||||
}
|
||||
|
||||
type ThemeType = 'system' | 'dark' | 'light';
|
||||
|
||||
const Theme = ({ theme, onChange }: { theme: string; onChange: (value: string) => void }) => {
|
||||
const themeIcons: Record<ThemeType, JSX.Element> = {
|
||||
system: <Monitor />,
|
||||
dark: <Moon color="white" />,
|
||||
light: <Sun />,
|
||||
};
|
||||
|
||||
const nextTheme = theme === 'dark' ? 'light' : 'dark';
|
||||
const label = `Switch to ${nextTheme} theme`;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') {
|
||||
e.preventDefault();
|
||||
onChange(nextTheme);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
return () => window.removeEventListener('keydown', handleKeyPress);
|
||||
}, [nextTheme, onChange]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-lg p-2 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
aria-label={label}
|
||||
aria-keyshortcuts="Ctrl+Shift+T"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(nextTheme);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(nextTheme);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{themeIcons[theme as ThemeType]}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ThemeSelector = ({ returnThemeOnly }: { returnThemeOnly?: boolean }) => {
|
||||
const { theme, setTheme } = useContext(ThemeContext);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
|
||||
const changeTheme = useCallback(
|
||||
(value: string) => {
|
||||
const now = Date.now();
|
||||
if (typeof window.lastThemeChange === 'number' && now - window.lastThemeChange < 500) {
|
||||
return;
|
||||
}
|
||||
window.lastThemeChange = now;
|
||||
|
||||
setTheme(value);
|
||||
setAnnouncement(value === 'dark' ? 'Dark theme enabled' : 'Light theme enabled');
|
||||
},
|
||||
[setTheme],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'system') {
|
||||
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
setTheme(prefersDarkScheme ? 'dark' : 'light');
|
||||
}
|
||||
}, [theme, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (announcement) {
|
||||
const timeout = setTimeout(() => setAnnouncement(''), 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [announcement]);
|
||||
|
||||
if (returnThemeOnly === true) {
|
||||
return <Theme theme={theme} onChange={changeTheme} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center bg-white pt-6 dark:bg-gray-900 sm:pt-0">
|
||||
<div className="absolute bottom-0 left-0 m-4">
|
||||
<Theme theme={theme} onChange={changeTheme} />
|
||||
</div>
|
||||
{announcement && (
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{announcement}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSelector;
|
||||
57
packages/client/src/components/Toast.tsx
Normal file
57
packages/client/src/components/Toast.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import * as RadixToast from '@radix-ui/react-toast';
|
||||
import { NotificationSeverity } from '~/common/types';
|
||||
import { useToast } from '~/hooks';
|
||||
|
||||
export function Toast() {
|
||||
const { toast, onOpenChange } = useToast();
|
||||
const severityClassName = {
|
||||
[NotificationSeverity.INFO]: 'border-gray-500 bg-gray-500',
|
||||
[NotificationSeverity.SUCCESS]: 'border-green-500 bg-green-500',
|
||||
[NotificationSeverity.WARNING]: 'border-orange-500 bg-orange-500',
|
||||
[NotificationSeverity.ERROR]: 'border-red-500 bg-red-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<RadixToast.Root
|
||||
open={toast.open}
|
||||
onOpenChange={onOpenChange}
|
||||
className="toast-root"
|
||||
style={{
|
||||
height: '74px',
|
||||
marginBottom: '0px',
|
||||
}}
|
||||
>
|
||||
<div className="w-full p-1 text-center md:w-auto md:text-justify">
|
||||
<div
|
||||
className={`alert-root pointer-events-auto inline-flex flex-row gap-2 rounded-md border px-3 py-2 text-white ${
|
||||
severityClassName[toast.severity]
|
||||
}`}
|
||||
>
|
||||
{toast.showIcon && (
|
||||
<div className="mt-1 flex-shrink-0 flex-grow-0">
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="icon-sm"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<RadixToast.Description className="flex-1 justify-center gap-2">
|
||||
<div className="whitespace-pre-wrap text-left">{toast.message}</div>
|
||||
</RadixToast.Description>
|
||||
</div>
|
||||
</div>
|
||||
</RadixToast.Root>
|
||||
);
|
||||
}
|
||||
75
packages/client/src/components/Tooltip.tsx
Normal file
75
packages/client/src/components/Tooltip.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { forwardRef, useMemo } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface TooltipAnchorProps extends Ariakit.TooltipAnchorProps {
|
||||
description: string;
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
className?: string;
|
||||
focusable?: boolean;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor(
|
||||
{ description, side = 'top', className, role, ...props },
|
||||
ref,
|
||||
) {
|
||||
const tooltip = Ariakit.useTooltipStore({ placement: side });
|
||||
const mounted = Ariakit.useStoreState(tooltip, (state) => state.mounted);
|
||||
const placement = Ariakit.useStoreState(tooltip, (state) => state.placement);
|
||||
|
||||
const { x, y } = useMemo(() => {
|
||||
const dir = placement.split('-')[0];
|
||||
switch (dir) {
|
||||
case 'top':
|
||||
return { x: 0, y: -8 };
|
||||
case 'bottom':
|
||||
return { x: 0, y: 8 };
|
||||
case 'left':
|
||||
return { x: -8, y: 0 };
|
||||
case 'right':
|
||||
return { x: 8, y: 0 };
|
||||
default:
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
}, [placement]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (role === 'button' && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
(event.target as HTMLDivElement).click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Ariakit.TooltipProvider store={tooltip} hideTimeout={0}>
|
||||
<Ariakit.TooltipAnchor
|
||||
{...props}
|
||||
ref={ref}
|
||||
role={role}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn('cursor-pointer', className)}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{mounted === true && (
|
||||
<Ariakit.Tooltip
|
||||
gutter={4}
|
||||
alwaysVisible
|
||||
className="tooltip"
|
||||
render={
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x, y }}
|
||||
animate={{ opacity: 1, x: 0, y: 0 }}
|
||||
exit={{ opacity: 0, x, y }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Ariakit.TooltipArrow />
|
||||
{description}
|
||||
</Ariakit.Tooltip>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Ariakit.TooltipProvider>
|
||||
);
|
||||
});
|
||||
51
packages/client/src/components/index.ts
Normal file
51
packages/client/src/components/index.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export * from './Accordion';
|
||||
export * from './AnimatedTabs';
|
||||
export * from './AlertDialog';
|
||||
export * from './Breadcrumb';
|
||||
export * from './Button';
|
||||
export * from './Checkbox';
|
||||
export * from './DataTableColumnHeader';
|
||||
export * from './Dialog';
|
||||
export * from './DropdownMenu';
|
||||
export * from './HoverCard';
|
||||
export * from './Input';
|
||||
export * from './InputNumber';
|
||||
export * from './Label';
|
||||
export * from './OriginalDialog';
|
||||
export * from './QuestionMark';
|
||||
export * from './Slider';
|
||||
export * from './Separator';
|
||||
export * from './InputCombobox';
|
||||
export * from './Skeleton';
|
||||
export * from './Switch';
|
||||
export * from './Table';
|
||||
export * from './Tabs';
|
||||
export * from './Tag';
|
||||
export * from './Textarea';
|
||||
export * from './TextareaAutosize';
|
||||
export * from './Toast';
|
||||
export * from './Tooltip';
|
||||
export * from './Pagination';
|
||||
export * from './Progress';
|
||||
export * from './InputOTP';
|
||||
export * from './MultiSearch';
|
||||
export * from './Resizable';
|
||||
export { default as Badge } from './Badge';
|
||||
export { default as Combobox } from './Combobox';
|
||||
export { default as Dropdown } from './Dropdown';
|
||||
export { default as SplitText } from './SplitText';
|
||||
export { default as DataTable } from './DataTable';
|
||||
export { default as FormInput } from './FormInput';
|
||||
export { default as PixelCard } from './PixelCard';
|
||||
export { default as FileUpload } from './FileUpload';
|
||||
export { default as MultiSelect } from './MultiSelect';
|
||||
export { default as DropdownPopup } from './DropdownPopup';
|
||||
export { default as DelayedRender } from './DelayedRender';
|
||||
export { default as ThemeSelector } from './ThemeSelector';
|
||||
export { default as CheckboxButton } from './CheckboxButton';
|
||||
export { default as DialogTemplate } from './DialogTemplate';
|
||||
export { default as SelectDropDown } from './SelectDropDown';
|
||||
export { default as ControlCombobox } from './ControlCombobox';
|
||||
export { default as OGDialogTemplate } from './OGDialogTemplate';
|
||||
export { default as InputWithDropdown } from './InputWithDropDown';
|
||||
export { default as AnimatedSearchInput } from './AnimatedSearchInput';
|
||||
88
packages/client/src/hooks/ThemeContext.old.tsx
Normal file
88
packages/client/src/hooks/ThemeContext.old.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
//ThemeContext.js
|
||||
// source: https://plainenglish.io/blog/light-and-dark-mode-in-react-web-application-with-tailwind-css-89674496b942
|
||||
import { useSetAtom } from 'jotai';
|
||||
import React, { createContext, useState, useEffect } from 'react';
|
||||
import { getInitialTheme, applyFontSize } from '~/utils';
|
||||
import { fontSizeAtom } from '~/store';
|
||||
|
||||
type ProviderValue = {
|
||||
theme: string;
|
||||
setTheme: React.Dispatch<React.SetStateAction<string>>;
|
||||
};
|
||||
|
||||
const defaultContextValue: ProviderValue = {
|
||||
theme: getInitialTheme(),
|
||||
setTheme: () => {
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
export const isDark = (theme: string): boolean => {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
return theme === 'dark';
|
||||
};
|
||||
|
||||
export const ThemeContext = createContext<ProviderValue>(defaultContextValue);
|
||||
|
||||
export const ThemeProvider = ({
|
||||
initialTheme,
|
||||
children,
|
||||
}: {
|
||||
initialTheme?: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [theme, setTheme] = useState(getInitialTheme);
|
||||
const setFontSize = useSetAtom(fontSizeAtom);
|
||||
|
||||
const rawSetTheme = (rawTheme: string) => {
|
||||
const root = window.document.documentElement;
|
||||
const darkMode = isDark(rawTheme);
|
||||
|
||||
root.classList.remove(darkMode ? 'light' : 'dark');
|
||||
root.classList.add(darkMode ? 'dark' : 'light');
|
||||
|
||||
localStorage.setItem('color-theme', rawTheme);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const changeThemeOnSystemChange = () => {
|
||||
rawSetTheme(mediaQuery.matches ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', changeThemeOnSystemChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', changeThemeOnSystemChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fontSize = localStorage.getItem('fontSize');
|
||||
if (fontSize == null) {
|
||||
setFontSize('text-base');
|
||||
applyFontSize('text-base');
|
||||
localStorage.setItem('fontSize', JSON.stringify('text-base'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
applyFontSize(JSON.parse(fontSize));
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
// Reason: This effect should only run once, and `setFontSize` is a stable function
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (initialTheme) {
|
||||
rawSetTheme(initialTheme);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
rawSetTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
|
||||
};
|
||||
10
packages/client/src/hooks/index.ts
Normal file
10
packages/client/src/hooks/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Theme exports are now handled by the theme module in the main index.ts
|
||||
|
||||
export type { TranslationKeys } from './useLocalize';
|
||||
|
||||
export { default as useToast } from './useToast';
|
||||
export { default as useCombobox } from './useCombobox';
|
||||
export { default as useLocalize } from './useLocalize';
|
||||
export { default as useMediaQuery } from './useMediaQuery';
|
||||
export { default as useDelayedRender } from './useDelayedRender';
|
||||
export { default as useOnClickOutside } from './useOnClickOutside';
|
||||
37
packages/client/src/hooks/useCombobox.ts
Normal file
37
packages/client/src/hooks/useCombobox.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import type { OptionWithIcon, MentionOption } from '~/common';
|
||||
|
||||
export default function useCombobox({
|
||||
value,
|
||||
options,
|
||||
}: {
|
||||
value: string;
|
||||
options: Array<OptionWithIcon | MentionOption>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const matches = useMemo(() => {
|
||||
if (!searchValue) {
|
||||
return options;
|
||||
}
|
||||
const keys = ['label', 'value'];
|
||||
const matches = matchSorter(options, searchValue, { keys });
|
||||
// Radix Select does not work if we don't render the selected item, so we
|
||||
// make sure to include it in the list of matches.
|
||||
const selectedItem = options.find((currentItem) => currentItem.value === value);
|
||||
if (selectedItem && !matches.includes(selectedItem)) {
|
||||
matches.push(selectedItem);
|
||||
}
|
||||
return matches;
|
||||
}, [searchValue, value, options]);
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
matches,
|
||||
};
|
||||
}
|
||||
37
packages/client/src/hooks/useDelayedRender.tsx
Normal file
37
packages/client/src/hooks/useDelayedRender.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const useDelayedRender = (delay: number) => {
|
||||
const [delayed, setDelayed] = useState(true);
|
||||
const timerPromiseRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (delayed) {
|
||||
const timerPromise = new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
setDelayed(false);
|
||||
resolve();
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
});
|
||||
|
||||
timerPromiseRef.current = timerPromise;
|
||||
}
|
||||
|
||||
return () => {
|
||||
timerPromiseRef.current = null;
|
||||
};
|
||||
}, [delay, delayed]);
|
||||
|
||||
return (fn: () => ReactNode) => {
|
||||
if (delayed && timerPromiseRef.current) {
|
||||
throw timerPromiseRef.current;
|
||||
}
|
||||
return fn();
|
||||
};
|
||||
};
|
||||
|
||||
export default useDelayedRender;
|
||||
21
packages/client/src/hooks/useLocalize.ts
Normal file
21
packages/client/src/hooks/useLocalize.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useEffect } from 'react';
|
||||
import { TOptions } from 'i18next';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { resources } from '~/locales/i18n';
|
||||
import { langAtom } from '~/store';
|
||||
|
||||
export type TranslationKeys = keyof typeof resources.en.translation;
|
||||
|
||||
export default function useLocalize() {
|
||||
const lang = useAtomValue(langAtom);
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (i18n.language !== lang) {
|
||||
i18n.changeLanguage(lang);
|
||||
}
|
||||
}, [lang, i18n]);
|
||||
|
||||
return (phraseKey: TranslationKeys, options?: TOptions) => t(phraseKey, options);
|
||||
}
|
||||
17
packages/client/src/hooks/useMediaQuery.tsx
Normal file
17
packages/client/src/hooks/useMediaQuery.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function useMediaQuery(query: string) {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches);
|
||||
}
|
||||
const listener = () => setMatches(media.matches);
|
||||
media.addEventListener('change', listener);
|
||||
return () => media.removeEventListener('change', listener);
|
||||
}, [matches, query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
41
packages/client/src/hooks/useOnClickOutside.ts
Normal file
41
packages/client/src/hooks/useOnClickOutside.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useEffect, RefObject } from 'react';
|
||||
type Handler = () => void;
|
||||
|
||||
export default function useOnClickOutside(
|
||||
ref: RefObject<HTMLElement>,
|
||||
handler: Handler,
|
||||
excludeIds: string[],
|
||||
customCondition?: (target: EventTarget | Element | null) => boolean,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
|
||||
if (target && 'id' in target && excludeIds.includes((target as HTMLElement).id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
target?.parentNode &&
|
||||
'id' in target.parentNode &&
|
||||
excludeIds.includes((target.parentNode as HTMLElement).id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (customCondition && customCondition(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ref.current && !ref.current.contains(target)) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ref, handler]);
|
||||
}
|
||||
58
packages/client/src/hooks/useToast.ts
Normal file
58
packages/client/src/hooks/useToast.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useAtom } from 'jotai';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import type { TShowToast } from '~/common';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { toastState, type ToastState } from '~/store';
|
||||
|
||||
export default function useToast(showDelay = 100) {
|
||||
const [toast, setToast] = useAtom(toastState);
|
||||
const showTimerRef = useRef<number | null>(null);
|
||||
const hideTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (showTimerRef.current !== null) {
|
||||
clearTimeout(showTimerRef.current);
|
||||
}
|
||||
if (hideTimerRef.current !== null) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showToast = ({
|
||||
message,
|
||||
severity = NotificationSeverity.SUCCESS,
|
||||
showIcon = true,
|
||||
duration = 3000, // default duration for the toast to be visible
|
||||
status,
|
||||
}: TShowToast) => {
|
||||
// Clear existing timeouts
|
||||
if (showTimerRef.current !== null) {
|
||||
clearTimeout(showTimerRef.current);
|
||||
}
|
||||
if (hideTimerRef.current !== null) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
}
|
||||
|
||||
// Timeout to show the toast
|
||||
showTimerRef.current = window.setTimeout(() => {
|
||||
setToast({
|
||||
open: true,
|
||||
message,
|
||||
severity: (status as NotificationSeverity) ?? severity,
|
||||
showIcon,
|
||||
});
|
||||
// Hides the toast after the specified duration
|
||||
hideTimerRef.current = window.setTimeout(() => {
|
||||
setToast((prevToast: ToastState) => ({ ...prevToast, open: false }));
|
||||
}, duration);
|
||||
}, showDelay);
|
||||
};
|
||||
|
||||
return {
|
||||
toast,
|
||||
onOpenChange: (open: boolean) => setToast({ ...toast, open }),
|
||||
showToast,
|
||||
};
|
||||
}
|
||||
24
packages/client/src/index.ts
Normal file
24
packages/client/src/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Components
|
||||
export * from './components';
|
||||
|
||||
// Hooks
|
||||
export * from './hooks';
|
||||
|
||||
// Common
|
||||
export * from './common';
|
||||
export * from './common/types';
|
||||
|
||||
// Store
|
||||
export * from './store';
|
||||
|
||||
// SVGs
|
||||
export * from './svgs';
|
||||
|
||||
// Utils
|
||||
export * from './utils';
|
||||
|
||||
// Providers
|
||||
export * from './Providers';
|
||||
|
||||
// Theme
|
||||
export * from './theme';
|
||||
47
packages/client/src/locales/Translation.spec.ts
Normal file
47
packages/client/src/locales/Translation.spec.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import i18n from './i18n';
|
||||
import English from './en/translation.json';
|
||||
import French from './fr/translation.json';
|
||||
import Spanish from './es/translation.json';
|
||||
|
||||
describe('i18next translation tests', () => {
|
||||
// Ensure i18next is initialized before any tests run
|
||||
beforeAll(async () => {
|
||||
if (!i18n.isInitialized) {
|
||||
await i18n.init();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return the correct translation for a valid key in English', () => {
|
||||
i18n.changeLanguage('en');
|
||||
expect(i18n.t('com_ui_examples')).toBe(English.com_ui_examples);
|
||||
});
|
||||
|
||||
it('should return the correct translation for a valid key in French', () => {
|
||||
i18n.changeLanguage('fr');
|
||||
expect(i18n.t('com_ui_examples')).toBe(French.com_ui_examples);
|
||||
});
|
||||
|
||||
it('should return the correct translation for a valid key in Spanish', () => {
|
||||
i18n.changeLanguage('es');
|
||||
expect(i18n.t('com_ui_examples')).toBe(Spanish.com_ui_examples);
|
||||
});
|
||||
|
||||
it('should fallback to English for an invalid language code', () => {
|
||||
// When an invalid language is provided, i18next should fallback to English
|
||||
i18n.changeLanguage('invalid-code');
|
||||
expect(i18n.t('com_ui_examples')).toBe(English.com_ui_examples);
|
||||
});
|
||||
|
||||
it('should return the key itself for an invalid key', () => {
|
||||
i18n.changeLanguage('en');
|
||||
expect(i18n.t('invalid-key')).toBe('invalid-key'); // Returns the key itself
|
||||
});
|
||||
|
||||
it('should correctly format placeholders in the translation', () => {
|
||||
i18n.changeLanguage('en');
|
||||
expect(i18n.t('com_endpoint_default_with_num', { 0: 'John' })).toBe('default: John');
|
||||
|
||||
i18n.changeLanguage('fr');
|
||||
expect(i18n.t('com_endpoint_default_with_num', { 0: 'Marie' })).toBe('par défaut : Marie');
|
||||
});
|
||||
});
|
||||
3
packages/client/src/locales/ar/translation.json
Normal file
3
packages/client/src/locales/ar/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "إلغاء"
|
||||
}
|
||||
3
packages/client/src/locales/ca/translation.json
Normal file
3
packages/client/src/locales/ca/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Cancel·la"
|
||||
}
|
||||
3
packages/client/src/locales/cs/translation.json
Normal file
3
packages/client/src/locales/cs/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Zrušit"
|
||||
}
|
||||
3
packages/client/src/locales/da/translation.json
Normal file
3
packages/client/src/locales/da/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Annuller"
|
||||
}
|
||||
3
packages/client/src/locales/de/translation.json
Normal file
3
packages/client/src/locales/de/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Abbrechen"
|
||||
}
|
||||
3
packages/client/src/locales/en/translation.json
Normal file
3
packages/client/src/locales/en/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Cancel"
|
||||
}
|
||||
3
packages/client/src/locales/es/translation.json
Normal file
3
packages/client/src/locales/es/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Cancelar"
|
||||
}
|
||||
3
packages/client/src/locales/et/translation.json
Normal file
3
packages/client/src/locales/et/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Tühista"
|
||||
}
|
||||
3
packages/client/src/locales/fa/translation.json
Normal file
3
packages/client/src/locales/fa/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "لغو کنید"
|
||||
}
|
||||
3
packages/client/src/locales/fi/translation.json
Normal file
3
packages/client/src/locales/fi/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Peruuta"
|
||||
}
|
||||
3
packages/client/src/locales/fr/translation.json
Normal file
3
packages/client/src/locales/fr/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Annuler"
|
||||
}
|
||||
3
packages/client/src/locales/he/translation.json
Normal file
3
packages/client/src/locales/he/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "בטל"
|
||||
}
|
||||
3
packages/client/src/locales/hu/translation.json
Normal file
3
packages/client/src/locales/hu/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Mégse"
|
||||
}
|
||||
87
packages/client/src/locales/i18n.ts
Normal file
87
packages/client/src/locales/i18n.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import translationEn from './en/translation.json';
|
||||
import translationAr from './ar/translation.json';
|
||||
import translationCa from './ca/translation.json';
|
||||
import translationCs from './cs/translation.json';
|
||||
import translationDa from './da/translation.json';
|
||||
import translationDe from './de/translation.json';
|
||||
import translationEs from './es/translation.json';
|
||||
import translationEt from './et/translation.json';
|
||||
import translationFa from './fa/translation.json';
|
||||
import translationFr from './fr/translation.json';
|
||||
import translationIt from './it/translation.json';
|
||||
import translationPl from './pl/translation.json';
|
||||
import translationPt_BR from './pt-BR/translation.json';
|
||||
import translationPt_PT from './pt-PT/translation.json';
|
||||
import translationRu from './ru/translation.json';
|
||||
import translationJa from './ja/translation.json';
|
||||
import translationKa from './ka/translation.json';
|
||||
import translationSv from './sv/translation.json';
|
||||
import translationKo from './ko/translation.json';
|
||||
import translationTh from './th/translation.json';
|
||||
import translationTr from './tr/translation.json';
|
||||
import translationVi from './vi/translation.json';
|
||||
import translationNl from './nl/translation.json';
|
||||
import translationId from './id/translation.json';
|
||||
import translationHe from './he/translation.json';
|
||||
import translationHu from './hu/translation.json';
|
||||
import translationFi from './fi/translation.json';
|
||||
import translationZh_Hans from './zh-Hans/translation.json';
|
||||
import translationZh_Hant from './zh-Hant/translation.json';
|
||||
|
||||
export const defaultNS = 'translation';
|
||||
|
||||
export const resources = {
|
||||
en: { translation: translationEn },
|
||||
ar: { translation: translationAr },
|
||||
ca: { translation: translationCa },
|
||||
cs: { translation: translationCs },
|
||||
'zh-Hans': { translation: translationZh_Hans },
|
||||
'zh-Hant': { translation: translationZh_Hant },
|
||||
da: { translation: translationDa },
|
||||
de: { translation: translationDe },
|
||||
es: { translation: translationEs },
|
||||
et: { translation: translationEt },
|
||||
fa: { translation: translationFa },
|
||||
fr: { translation: translationFr },
|
||||
it: { translation: translationIt },
|
||||
pl: { translation: translationPl },
|
||||
'pt-BR': { translation: translationPt_BR },
|
||||
'pt-PT': { translation: translationPt_PT },
|
||||
ru: { translation: translationRu },
|
||||
ja: { translation: translationJa },
|
||||
ka: { translation: translationKa },
|
||||
sv: { translation: translationSv },
|
||||
ko: { translation: translationKo },
|
||||
th: { translation: translationTh },
|
||||
tr: { translation: translationTr },
|
||||
vi: { translation: translationVi },
|
||||
nl: { translation: translationNl },
|
||||
id: { translation: translationId },
|
||||
he: { translation: translationHe },
|
||||
hu: { translation: translationHu },
|
||||
fi: { translation: translationFi },
|
||||
} as const;
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: {
|
||||
'zh-TW': ['zh-Hant', 'en'],
|
||||
'zh-HK': ['zh-Hant', 'en'],
|
||||
zh: ['zh-Hans', 'en'],
|
||||
default: ['en'],
|
||||
},
|
||||
fallbackNS: 'translation',
|
||||
ns: ['translation'],
|
||||
debug: false,
|
||||
defaultNS,
|
||||
resources,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
3
packages/client/src/locales/id/translation.json
Normal file
3
packages/client/src/locales/id/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Batal"
|
||||
}
|
||||
3
packages/client/src/locales/it/translation.json
Normal file
3
packages/client/src/locales/it/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Annulla"
|
||||
}
|
||||
3
packages/client/src/locales/ja/translation.json
Normal file
3
packages/client/src/locales/ja/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "キャンセル"
|
||||
}
|
||||
3
packages/client/src/locales/ka/translation.json
Normal file
3
packages/client/src/locales/ka/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "გაუქმება"
|
||||
}
|
||||
3
packages/client/src/locales/ko/translation.json
Normal file
3
packages/client/src/locales/ko/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "취소"
|
||||
}
|
||||
3
packages/client/src/locales/nl/translation.json
Normal file
3
packages/client/src/locales/nl/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Annuleren"
|
||||
}
|
||||
3
packages/client/src/locales/pl/translation.json
Normal file
3
packages/client/src/locales/pl/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Anuluj"
|
||||
}
|
||||
3
packages/client/src/locales/pt-BR/translation.json
Normal file
3
packages/client/src/locales/pt-BR/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Cancelar"
|
||||
}
|
||||
3
packages/client/src/locales/pt-PT/translation.json
Normal file
3
packages/client/src/locales/pt-PT/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Cancelar"
|
||||
}
|
||||
3
packages/client/src/locales/ru/translation.json
Normal file
3
packages/client/src/locales/ru/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Отмена"
|
||||
}
|
||||
3
packages/client/src/locales/sv/translation.json
Normal file
3
packages/client/src/locales/sv/translation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"com_ui_cancel": "Avbryt"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue