🚀 feat: launch v1.0 — next-generation frontend built from the ground up (#4265)
* feat: add parameter coverage for the operations: copy, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, and regex_replace * fix: CrossGroupRetry default false 移除gorm:"default:false",避免每次 AutoMigrate时都执行ALTER TABLE `tokens` MODIFY COLUMN `cross_group_retry` boolean DEFAULT false 且bool默认false不影响原有功能 * feat: check-in feature integrates Turnstile security check * feat: add support for Doubao /v1/responses (#2567) * feat: add support for Doubao /v1/responses * fix: fix model deployment style issues, lint problems, and i18n gaps. (#2556) * fix: fix model deployment style issues, lint problems, and i18n gaps. * fix: adjust the key not to be displayed on the frontend, tested via the backend. * fix: adjust the sidebar configuration logic to use the default configuration items if they are not defined. * feat: add plans directory to .gitignore * fix: 修复 gemini 文件类型不支持 image/jpg * fix: fix the proxyURL is empty, not using the default HTTP client configuration && the AWS calling side did not apply the relay timeout. * fix: batch add key backend deduplication * Merge pull request #2582 from seefs001/fix/tips fix: add tips for model management and channel testing * fix(gin): update request body size check to allow zero limit * feat: add regex pattern to mask API keys in sensitive information * fix(task): 修复使用 auto 分组时 Task Relay 不记录日志和不扣费的问题 问题描述: - 使用 auto 分组的令牌调用 /v1/videos 等 Task 接口时,虽然任务能成功创建, 但使用日志不显示记录,且不会扣费 根本原因: - Distribute 中间件在选择渠道后,会将实际选中的分组存储在 ContextKeyAutoGroup 中 - 但 RelayTaskSubmit 函数没有从 context 中读取这个值来更新 info.UsingGroup - 导致 info.UsingGroup 始终是 "auto" 而不是实际选中的分组(如 "sora2逆") - 当 auto 分组的倍率配置为 0 时,quota 计算结果为 0 - 日志记录条件 "if quota != 0" 不满足,导致日志不记录、不扣费 修复方案: - 在 RelayTaskSubmit 函数中计算分组倍率之前,添加从 ContextKeyAutoGroup 获取实际分组的逻辑 - 使用安全的类型断言,避免潜在的 panic 风险 影响范围: - 仅影响 Task Relay 流程(/v1/videos, /suno, /kling 等接口) - 不影响使用具体分组令牌的调用 - 不影响其他 Relay 类型(chat/completions 等已有类似处理逻辑) * 🚀 feat(web): port legacy v2 frontend changes into new UI (deployments, check-in, ollama) + align APIs Bring over the key frontend functionality introduced in merge `efa3301` and integrate it cleanly into the new `web/src` architecture and design system. - **Model deployments (io.net)** - Align frontend endpoints and payloads with backend deployment routes (`/api/deployments/*`) - Add missing deployment operations: details, logs (container-aware), update config, rename, extend duration - Improve create-deployment flow (proper request shape, name availability check, price estimation parity) - **System settings** - Enhance io.net deployment settings: allow testing connection with an unsaved API key and add “how to get API key” guidance - **Channels / Ollama** - Improve Ollama model management: live fetch via base_url with fallback to channel fetch, selection + apply flows, delete confirmation - Refactor for feature-layer consistency: extract Ollama parsing/normalization utilities into `features/channels/lib` - **Quality** - Ensure TypeScript typecheck passes after refactor and new dialogs/components integration * Merge pull request #2590 from xyfacai/fix/max-body-limit fix: 设置默认max req body 为128MB * docs: update readme * i18n: add missing translations * fix(gemini): fetch model list via native v1beta/models endpoint Use the native Gemini Models API (/v1beta/models) instead of the OpenAI-compatible path when listing models for Gemini channels, improving compatibility with third-party Gemini-format providers that don't implement OpenAI routes. - Add paginated model listing with timeout and optional proxy support - Select an enabled key for multi-key Gemini channels * refactor(gemini): 更新 GeminiModelsResponse 以使用 dto.GeminiModel 类型 * fix: remove Minimax from FETCHABLE channels * fix(minimax): 添加 MiniMax-M2 系列模型到 ModelList * feat: add doubao video 1.5 * 🤢 chore: remove useless file * feat: /v1/chat/completion -> /v1/response (#2629) * feat: /v1/chat/completion -> /v1/response * fix: clean propertyNames for gemini function * fix: support snake_case fields in GeminiChatGenerationConfig * chore: update dependencies and lockfile for improved compatibility - Updated @clerk/clerk-react to version 5.59.3 - Updated @hookform/resolvers to version 5.2.2 - Updated @lobehub/icons to version 2.48.0 - Updated various Radix UI components to their latest versions - Updated @tanstack/react-query and related packages for better performance - Updated axios, i18next, and other libraries for security and feature enhancements - Updated lockfile to include configVersion and ensure consistency across environments * Merge pull request #2647 from seefs001/feature/status-code-auto-disable feat: status code auto-disable configuration * fix: chat2response setting ui (#2643) * fix: setting ui * fix: rm global.chat_completions_to_responses_policy * fix: rm global.chat_completions_to_responses_policy * Merge pull request #2627 from seefs001/feature/channel-test-param-override feat: channel testing supports parameter overriding * chore: update dependencies and lockfile for improved compatibility - Updated @lobehub/icons to version 4.0.3 - Updated ai to version 6.0.27 - Updated various libraries including axios, react-day-picker, and streamdown for security and feature enhancements - Updated devDependencies for eslint, prettier, and typescript for better performance and compatibility - Updated lockfile to ensure consistency across environments * chore: update lockfile and Vite configuration for improved build process - Updated lockfile to version 1 for better compatibility and consistency - Enhanced Vite configuration to support production optimizations, including code minification and chunking for dependencies - Added environment-specific console and debugger removal for production builds * chore: migrate from Vite to Rsbuild for build process - Added Rsbuild configuration for development and production builds - Updated package.json scripts to use Rsbuild instead of Vite - Replaced @tailwindcss/vite with @tailwindcss/postcss in dependencies - Introduced postcss.config.mjs for Tailwind CSS integration - Updated TypeScript configuration to include Rsbuild config - Removed Vite configuration file to streamline the build process * refactor: optimize user data handling and API calls - Replaced direct API calls to get user data with cached user information from auth-store in ModelsFilter and SummaryCards components. - Improved session management in RootComponent and Authenticated route to utilize localStorage for user authentication status, reducing unnecessary API requests. - Added caching for setup status checks to enhance performance during navigation. * feat: enhance session validation in authenticated route - Implemented session verification to check user authentication status via API call only once per session. - Updated beforeLoad logic to redirect users to the login page if session validation fails or if no user information is available in localStorage. - Improved user data handling by updating the auth store with fresh user information upon successful session verification. * refactor: improve useMediaQuery hook for better SSR handling - Enhanced the useMediaQuery hook to check for window availability before accessing matchMedia, preventing errors during server-side rendering. - Simplified state initialization and change handling by using a dedicated function to determine initial matches. - Updated event listener management for improved performance and clarity. * feat(hooks): export useMediaQuery from hooks index * refactor: update useMediaQuery imports to use unified hooks index * fix(rsbuild): fix loadEnv API usage and removeConsole type * feat: customizable automatic retry status codes * refactor(hooks): use useSyncExternalStore for better SSR handling in useMediaQuery * refactor: simplify embedded file structure in main.go - Updated the embedded file directive to include the entire web/dist directory instead of individual assets, streamlining the build process. * refactor: replace DropdownMenu with Sheet component in ProfileDropdown - Updated the ProfileDropdown component to use a Sheet for user interactions instead of a DropdownMenu. - Enhanced user info display with improved layout and styling. - Added navigation links and sign-out functionality within the Sheet. * refactor: streamline ProfileDropdown layout and improve user info display - Removed unused Badge component and secondary text from user display. - Enhanced styling for user info section and navigation links. - Updated sign-out functionality to use a button for better accessibility. * feat: add System Settings link for super admin in ProfileDropdown - Introduced a new link to System Settings in the ProfileDropdown, visible only to users with the SUPER_ADMIN role. - Updated imports to include the Settings icon and adjusted the component logic accordingly. - Removed the Settings entry from the sidebar data to streamline navigation. * feat: codex channel (#2652) * feat: codex channel * feat: codex channel * feat: codex oauth flow * feat: codex refresh cred * feat: codex usage * fix: codex err message detail * fix: codex setting ui * feat: codex refresh cred task * fix: import err * fix: codex store must be false * fix: chat -> responses tool call * fix: chat -> responses tool call * feat(i18n): add missing translations * fix(i18n): restore missing translations for "360" and add "User Menu" in multiple locales - Reintroduced the translation for "360" in English, French, Japanese, Russian, Vietnamese, and Chinese locales. - Added the "User Menu" translation in the same languages to enhance user interface consistency. * fix: openAI function to gemini function field adjusted to whitelist mode * feat: TLS_INSECURE_SKIP_VERIFY env * fix: for chat-based calls to the Claude model, tagging is required. Using Claude's rendering logs, the two approaches handle input rendering differently. * refactor(system-settings): restructure settings sections and navigation - Replaced SettingsAccordion with a unified SettingsSection component across various settings sections for consistency. - Introduced a section registry to manage general settings sections dynamically. - Updated navigation items in the system settings sidebar to utilize the new section registry. - Enhanced the GeneralSettings component to support section-based content rendering based on user selection. * fix(system-settings): remove type assertion for quotaDisplayType in GeneralSettings - Eliminated the type assertion for quotaDisplayType in the GeneralSettings component to improve type inference and maintain cleaner code. * refactor(system-settings): update zod import syntax in general settings - Changed the import statement for zod from a default import to a namespace import for better clarity and consistency in the codebase. * fix: the login method cannot be displayed under the aff link. * feat(system-settings): implement generic settings page and enhance navigation - Added a new generic SettingsPage component to handle loading states, data fetching, and section rendering. - Integrated section registry for general and authentication settings to streamline navigation and content management. - Updated URL utility functions to improve query parameter handling for active navigation states. - Enhanced the system settings sidebar to include authentication section items dynamically. * refactor(system-settings): replace SettingsAccordion with SettingsSection across authentication settings - Updated BasicAuthSection, BotProtectionSection, OAuthSection, and PasskeySection to use the new SettingsSection component for consistency. - Introduced a section registry to manage authentication settings dynamically, enhancing navigation and content rendering. * feat(system-settings): enhance request limits settings with new section and unified component - Added a new Request Limits section to the system settings sidebar, integrating it with the section registry for improved navigation. - Replaced SettingsAccordion with SettingsSection in RateLimitSection, SensitiveWordsSection, and SSRFSection for consistency. - Updated RequestLimitsSettings to utilize the new SettingsPage component for better data handling and rendering. - Implemented a search schema for request limits to streamline navigation and section management. * feat(system-settings): integrate content settings sections with unified component and registry - Added a new Content section to the system settings sidebar, incorporating it into the section registry for improved navigation. - Replaced SettingsAccordion with SettingsSection in multiple content-related components for consistency. - Created a section registry to manage content settings dynamically, enhancing the rendering and navigation experience. - Updated the ContentSettings component to utilize the new section registry and streamline content display. * feat(system-settings): enhance integrations settings with unified section registry and components - Introduced a new section registry for integrations settings, consolidating various settings components for better organization and navigation. - Replaced SettingsAccordion with SettingsSection in multiple integration-related components for consistency. - Updated IntegrationSettings to utilize the new SettingsPage component, improving data handling and rendering. - Added a new integrations section to the system settings sidebar, enhancing user experience and accessibility. * feat(system-settings): unify model settings with new section registry and components - Introduced a section registry for model settings, consolidating various model-related components for improved organization and navigation. - Replaced SettingsAccordion with SettingsSection in multiple model settings components for consistency. - Updated ModelSettings to utilize the new SettingsPage component, enhancing data handling and rendering. - Added a new Models section to the system settings sidebar, improving user experience and accessibility. * feat(system-settings): enhance maintenance settings with unified section registry and components - Introduced a new section registry for maintenance settings, consolidating various maintenance-related components for improved organization and navigation. - Replaced SettingsAccordion with SettingsSection in multiple maintenance components for consistency. - Updated MaintenanceSettings to utilize the new section registry, enhancing data handling and rendering. - Added a new Maintenance section to the system settings sidebar, improving user experience and accessibility. * feat(system-settings): update section titles for improved clarity and consistency - Renamed various section titles across content, integrations, maintenance, models, and request limits to enhance clarity and better reflect their functionalities. - Adjusted titles such as 'Dashboard' to 'Data Dashboard', 'API Info' to 'API Addresses', and 'Update Checker' to 'System maintenance' for improved user understanding. - Ensured consistency in naming conventions across all settings sections to streamline user experience and navigation. * feat(nav-group): enhance collapsible menu behavior and URL matching logic - Added controlled state management for collapsible menu items to automatically expand based on active sub-item paths. - Updated the URL matching logic in checkIsActive to improve handling of query parameters and ensure accurate navigation state detection. - Refactored the collapsible component to utilize the new state management, enhancing user experience in the sidebar navigation. * feat(system-settings): update system settings navigation and redirect logic - Changed the link in the profile dropdown to point directly to the general section of system settings with a search parameter for section identification. - Implemented a redirect in the general settings route to ensure users are directed to the default section if no section parameter is provided, enhancing navigation consistency. * feat(system-settings): unify route configuration for settings sections - Refactored route configuration for various system settings sections (auth, content, general, integrations, maintenance, models, request limits) to utilize a new `createSettingsRouteConfig` function. - This change consolidates the repetitive logic of creating search schemas and handling redirects, improving code maintainability and readability. - Enhanced navigation by ensuring default sections are loaded when no section parameter is provided. * feat(url-utils): enhance URL handling and matching logic - Introduced a new utility function `urlToString` to convert various URL formats (string and object) into a standardized string format. - Updated the `checkIsActive` function to utilize `urlToString`, improving the accuracy of URL matching and handling of query parameters. - Refactored URL comparison logic to ensure consistent behavior across different URL types, enhancing navigation state detection. * feat(system-settings): validate DataExportDefaultTime for improved data handling - Introduced a new function `validateDataExportDefaultTime` to ensure the `DataExportDefaultTime` value is either 'week', 'hour', or 'day', defaulting to 'hour' for unexpected values. - Updated the `DataExportDefaultTime` assignment in the settings section to utilize this validation function, enhancing data integrity and user experience. * perf(system-settings): Improve the i18n of system settings content - Changed button labels in various sections to use consistent capitalization and translation functions, enhancing user experience. - Updated validation messages in schemas to utilize translation functions for improved internationalization support. - Ensured all user-facing strings are properly translated, improving accessibility for non-English users. * fix(system-settings): update ApiInfoFormValues type inference for improved schema validation - Changed the type inference for ApiInfoFormValues to utilize ReturnType of createApiInfoSchema, ensuring accurate type representation and enhancing type safety in the API info section. * fix(chat-settings): improve validation logic for chat settings schema - Updated the validation logic to ensure that null values are correctly handled and that only objects are accepted as valid items in the chat settings schema. - Simplified error handling by removing the error message from the catch block, providing a consistent user-facing message for invalid JSON strings. * fix(system-settings): enhance validation error handling in uptime-kuma schema - Updated the validation logic for category name, URL, and slug fields to use an object format for error messages, improving clarity and consistency in user feedback. - Ensured that all validation messages are properly structured to enhance internationalization support. * fix(i18n): add translations for Uptime Kuma group management - Added English, French, Japanese, Russian, Vietnamese, and Chinese translations for "Add Uptime Kuma Group" and "Edit Uptime Kuma Group" to enhance internationalization support. - Included validation messages for category name and slug fields across multiple languages to improve user feedback and accessibility. * fix(system-settings): improve validation error message structure for SystemName - Updated the validation logic for the SystemName field to use an object format for error messages, enhancing clarity and consistency in user feedback. - This change aligns with recent improvements in internationalization support across the system settings schemas. * perf(i18n): add new validation error message translations - Added translations for the new validation error message "Invalid JSON format or values out of allowed range" in English, French, Japanese, Russian, Vietnamese, and Chinese. - This update enhances internationalization support by ensuring users receive clear feedback across multiple languages. * fix(i18n): update Japanese translation for payment method configuration message - Corrected the Japanese translation for the message regarding payment methods configuration to use the term "メソッド" instead of "方法" for improved accuracy and consistency in user feedback. - This change enhances the clarity of the message for Japanese-speaking users. * fix(i18n): remove unnecessary loading messages from French translations - Removed the French translations for "Loading settings...", "Loading maintenance settings...", and "Loading content settings..." to streamline the localization file. - This change improves the clarity and relevance of the translations provided to users. * fix(i18n): add translations for Uptime Kuma group management in multiple languages - Added French, Japanese, Russian, Vietnamese, and Chinese translations for "Add Uptime Kuma Group" and "Edit Uptime Kuma Group" to enhance internationalization support. - This update improves user experience by providing clear and consistent messaging across different languages. * fix(validation): enhance pricing schema error messages and add translations - Updated the pricing schema to include localized error messages for validation, ensuring users receive clear feedback when input values are invalid. - Added new translations for "Exchange rate is required" and "Exchange rate must be greater than 0" in English, French, Japanese, and Chinese to improve internationalization support. - This change enhances user experience by providing accurate and contextually relevant messages across multiple languages. * fix: codex Unsupported parameter: max_output_tokens * fix(model-mapping-editor): simplify JSON parsing logic in useEffect * fix: jimeng i2v support multi image by metadata * refactor(models): restructure models section handling and improve UI components - Replaced tab-based navigation with section-based navigation for better clarity and organization. - Introduced a new section registry to manage model sections, including 'metadata' and 'deployments'. - Updated the ModelsContent component to reflect the new section structure and added a Create Deployment button. - Removed the ModelsTabs component as it was no longer needed. - Enhanced internationalization support by adding new translations for section descriptions and management tasks. - Adjusted sidebar configuration to accommodate the new section structure. * fix: update warning threshold label from '5$' to '2$' * fix: video content api Priority use url field * fix: update abortWithOpenAiMessage function to use types.ErrorCode * feat(deployment): introduce CreateDeploymentDrawer component and update dialog references - Replaced the CreateDeploymentDialog with a new CreateDeploymentDrawer component for improved user experience. - Added comprehensive form handling for deployment creation, including validation and price estimation features. - Updated internationalization files to include new translations for UI elements and descriptions related to deployment configuration. - Enhanced the ModelsContent component to integrate the new drawer for creating deployments. * perf(i18n): enhance internationalization for models table and columns - Updated labels and titles in the ModelsTable and useModelsColumns components to utilize translation functions for improved localization. - Changed static text for vendor and sync status to dynamic translations, enhancing user experience for non-English speakers. - Updated empty state messages in the ModelsTable to support internationalization, ensuring clarity for all users. * fix: fix email send * fix: issue where consecutive calls to multiple tools in gemini all returned an index of 0 * fix: replace Alibaba's Claude-compatible interface with the new interface * fix: Only models with the "qwen" designation can use the Claude-compatible interface; others require conversion. * feat: log shows request conversion * feat: optimized display * feat: optimized display * feat: optimized display * fix: codex rm Temperature * Revert "fix: video content api Priority use url field" * feat: requestId time string use UTC * feat(qwen): support qwen image sync image model config * feat: sync old ui * feat: more ui sync * feat: replace theme * fix build * refactor(web): revert theme colors and variables in CSS Updated color variables for light and dark themes to improve consistency and visual appeal. * feat(deployment): enhance deployment access guard and model deployment settings - Introduced loading phase management in the DeploymentAccessGuard component to provide better user feedback during connection checks. - Updated the ModelsContent component to prefetch the deployments list while checking connection status, improving data readiness. - Implemented a caching mechanism for connection status in useModelDeploymentSettings to optimize performance and reduce unnecessary API calls. - Enhanced loading states and error handling for improved user experience during deployment settings retrieval and connection testing. * feat(i18n): add new translations for connection and loading states across multiple languages - Introduced translations for "Checking connection" and "Loading configuration" in English, French, Japanese, Russian, Vietnamese, and Chinese. - This update enhances the internationalization support, providing clearer user feedback during connection checks and loading phases. * refactor(pagination): adjust layout and styling for pagination component - Updated the pagination component to improve layout by removing unnecessary width constraints and enhancing responsiveness. - Increased minimum width for pagination text to ensure better visibility and alignment across different screen sizes. * feat(i18n): implement translations for various UI elements across multiple components - Updated several components to utilize the translation function for titles and placeholders, enhancing internationalization support. - Added new translation entries for "Filter by name or key..." and "Log Type" in English, French, Japanese, Russian, Vietnamese, and Chinese. - This update improves user experience by providing localized text in the ChannelsTable, SummaryCards, ApiKeysTable, RedemptionsTable, UsageLogsTable, and UsersTable components. * feat(i18n): integrate translation support in SummaryCards component - Added the useTranslation hook to the SummaryCards component to enhance internationalization. - This update allows for localized text rendering, improving user experience for diverse language speakers. * feat(dashboard): refactor dashboard structure and introduce section-based navigation - Removed the tab navigation in favor of a section-based approach, enhancing user experience by providing clearer context for the dashboard content. - Introduced a new section registry to manage dashboard sections, allowing for easier expansion and maintenance. - Updated sidebar configuration to reflect the new section structure, ensuring proper navigation links are displayed. - Added translations for new section titles and descriptions to support internationalization. * feat(i18n): update time range labels and enhance translation support - Changed time range labels from shorthand (e.g., '1D') to full text (e.g., '1 Day') for better clarity. - Updated various components to utilize the translation function for time range labels, improving internationalization. - Added new translation entries for time ranges in English, French, Japanese, Russian, Vietnamese, and Chinese, enhancing user experience across languages. * feat(dashboard): enhance type safety and improve component structure - Updated the Dashboard component to use specific types for model data and filters, enhancing type safety. - Introduced new types for announcements and FAQs, improving clarity and maintainability. - Refactored LogStatCards and UptimePanel components to utilize AbortController for better data fetching management. - Optimized the rendering of announcements and FAQs by using unique keys based on item IDs. - Improved theme management in ModelCharts by caching the ThemeManager import to reduce dynamic imports. * feat(agents): add comprehensive guidelines for React and Next.js development - Introduced a new set of best practices and optimization techniques for React and Next.js applications, aimed at enhancing performance and maintainability. - Included detailed rules covering various aspects such as event handling, API routes, rendering strategies, and state management. - Added extensive documentation in AGENTS.md and SKILL.md to support developers in adhering to these practices. - This update serves as a foundational resource for improving code quality and efficiency in React-based projects. * chore(web): update package.json dependencies - Removed outdated dependencies including @base-ui/react, @clerk/clerk-react, and others to streamline the project. - Updated remaining dependencies to their latest versions for improved performance and security. - This cleanup enhances the overall maintainability of the project. * feat(usage-logs): implement section-based navigation and enhance log management - Introduced a section registry for usage logs, allowing for better organization and navigation between different log categories (common, drawing, task). - Updated the UsageLogsContent component to dynamically render titles and descriptions based on the selected section. - Refactored UsageLogsTable and UsageLogsPrimaryButtons components to accept the active log category as a prop, improving modularity. - Enhanced sidebar configuration to support new section navigation, ensuring users can easily access different log types. - Updated routing to redirect to the default section if none is specified, improving user experience. * feat(i18n): enhance internationalization across usage logs components - Integrated the useTranslation hook in various components related to usage logs, including CommonLogsStats, UsageLogsTable, and column helpers. - Updated labels, titles, and messages to utilize translation functions, improving localization support. - Added new translation entries for log-related terms in English, French, Japanese, Russian, Vietnamese, and Chinese, enhancing user experience for diverse language speakers. * feat(datetime-picker): integrate dayjs for date formatting - Added dayjs as a dependency to the project for improved date handling. - Updated the DateTimePicker component to use dayjs for formatting dates, enhancing consistency and readability of date displays. * feat(date-handling): replace date-fns with dayjs for improved date management - Updated the project to use dayjs instead of date-fns for date formatting and manipulation, enhancing consistency across components. - Refactored DatePicker, DateTimePicker, and other components to utilize dayjs for date-related functionalities. - Added a new dayjs configuration file to extend its capabilities with relative time support. - Updated AGENTS.md to reflect the new technology stack, emphasizing the use of dayjs for date handling. * refactor(agents): streamline front-end development guidelines and update technology stack - Revised AGENTS.md to condense front-end development standards and best practices, making it more accessible for developers and AI assistants. - Updated the technology stack section to reflect current dependencies, emphasizing the use of Bun, React 19, TypeScript, and other key libraries. - Enhanced the document structure with a new table format for better readability and navigation, including a comprehensive table of contents for quick access to sections. * feat(i18n): enhance date picker and datetime picker localization support - Integrated internationalization support in DatePicker and DateTimePicker components by adding locale handling for multiple languages (English, French, Japanese, Russian, Vietnamese, Chinese). - Updated the calendar component to accept a locale prop, ensuring proper localization of month and weekday labels. - Improved user experience by allowing date selection to adapt based on the user's language preference. * feat(layout): add SectionPageLayout component for structured page layouts - Introduced a new SectionPageLayout component to facilitate structured layouts for pages with sections, enhancing the organization of content. - Added subcomponents for Title, Description, Actions, and Content to improve clarity and maintainability of page structures. - Updated AGENTS.md to include guidelines on avoiding unnecessary destructuring of props for better code readability. * feat(layout): refactor components to use SectionPageLayout for improved structure - Replaced AppHeader and Main components with SectionPageLayout across multiple features including Channels, Dashboard, ApiKeys, Models, Redemption Codes, Usage Logs, Users, and Wallet. - Enhanced page organization by utilizing SectionPageLayout's Title, Description, Actions, and Content subcomponents, improving clarity and maintainability. - This update standardizes the layout structure across the application, facilitating a more cohesive user experience. * feat(usage-logs): enhance URL state management and redirection logic - Added useEffect to synchronize column filters with URL search changes, preventing infinite loops caused by inline references. - Improved redirection logic in usage logs to clear 'type' from the URL when the section is not 'common', enhancing user experience and URL cleanliness. * fix(usage-logs): disable global filter and update DataTableToolbar props - Disabled the global filter in the UsageLogsTable component to streamline the user interface. - Updated the DataTableToolbar component to accept a null customSearch prop, enhancing flexibility in toolbar configuration. * feat(routes): implement section-based routing for system settings and dashboard - Introduced section-based routing for system settings and dashboard features, enhancing navigation and organization. - Updated route definitions to include dynamic sections, allowing for more granular access to settings and dashboard components. - Refactored existing routes to redirect to default sections when no specific section is provided, improving user experience. - Added new section routes for models, usage logs, and system settings, ensuring consistency across the application. - Removed deprecated routes to streamline the routing structure and improve maintainability. * refactor(usage-logs): update column helper functions to require config parameter - Modified createFailReasonColumn and createProgressColumn functions to require a config parameter instead of allowing it to be optional. - Simplified destructuring of config to enhance clarity and ensure necessary properties are always provided, improving code reliability. * refactor(usage-logs): improve section ID validation and routing logic - Introduced a type guard function, isUsageLogsSectionId, to validate section IDs, enhancing type safety and reducing the need for casting. - Updated UsageLogsContent to utilize the new validation function for determining the active category, improving clarity and reliability. - Refactored routing logic to use isUsageLogsSectionId for section validation, ensuring proper redirection to the default section when necessary. * refactor(calendar): update locale documentation for i18n support - Revised the locale prop documentation in the Calendar component to specify the use of react-day-picker for internationalization, clarifying the expected locale setup for users. * chore(i18n): remove redundant user information description from locale files - Removed the user information description from English, French, Japanese, Russian, Vietnamese, and Chinese locale files to streamline translations and improve clarity. * chore(i18n): streamline locale files by removing redundant entries - Removed unnecessary entries from English, French, Japanese, Russian, Vietnamese, and Chinese locale files to enhance clarity and reduce clutter. - Adjusted translations for consistency and improved user experience across multiple languages. * chore(sidebar): remove deprecated usage logs route from sidebar config - Eliminated the '/usage-logs' entry from the sidebar configuration to streamline navigation and improve clarity in the sidebar structure. * refactor(redemption-codes): enhance internationalization support and improve UI consistency - Updated various components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages. - Added meta labels for table columns to improve accessibility and clarity. - Revised confirmation and action texts in dialogs and tooltips to leverage translation, enhancing user experience. - Updated locale files to include new translations for improved clarity and consistency. * feat(masked-value-display): add MaskedValueDisplay component for sensitive data handling - Introduced a new MaskedValueDisplay component to display masked values with a popover for full value visibility and a copy button for easy access. - Updated api-keys-columns and redemptions-columns to utilize the new component, enhancing code reusability and UI consistency. - Revised translation keys in locale files to remove colons for improved clarity. * refactor(url-utils): simplify query parameter matching logic in checkIsActive function - Updated the checkIsActive function to streamline the logic for matching URLs with and without query parameters. - Removed unnecessary checks for query parameters when matching base paths, improving clarity and maintainability of the code. * fix(channels-table): update group filter label to use translation function - Replaced hardcoded 'All Groups' label with a translation function call to enhance internationalization support in the ChannelsTable component. * chore(api-keys): remove deprecated API key action messages and related exports - Deleted the api-key-actions.ts file, which contained action messages for enabling, disabling, and deleting API keys. - Updated index.ts to remove the export of getApiKeyActionMessage, streamlining the codebase by eliminating unused functionality. * refactor(i18n): enhance internationalization support across various components - Updated multiple components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages. - Revised constants and labels in the channels and redemption codes features to use i18n keys, improving maintainability and clarity. - Ensured that success and error messages leverage translation functions, enhancing user experience and accessibility. - Streamlined the handling of i18n keys in the constants files for better organization and clarity. * refactor(i18n): enhance translation support across various components - Updated multiple components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages. - Revised pagination and status labels to use i18n keys, improving maintainability and clarity. - Enhanced response time formatting to support internationalization, allowing for localized display of time values. - Updated locale files to include new translations for improved clarity and consistency. * docs(AGENTS): add type checking requirement for TypeScript changes - Included a new guideline stating that type checks must be executed after modifying TypeScript or TSX code, ensuring no type errors are left unresolved. - Updated the document to reflect this addition in the relevant section for better clarity on coding standards. * feat(combobox-input): add ComboboxInput component for enhanced token selection - Introduced a new ComboboxInput component to facilitate token name selection with search and filtering capabilities. - Integrated the ComboboxInput into the UsageLogsFilterDialog for improved user experience when filtering by token name. - Updated locale files to include new translations for user-facing strings related to token filtering. * feat(combobox): integrate translation support for custom value prompt - Added translation functionality to the Combobox component, replacing hardcoded text with a translatable string for the custom value prompt. - Utilized the useTranslation hook from react-i18next to enhance internationalization support, ensuring a consistent user experience across different languages. * refactor(i18n): improve Chinese translations for consistency and clarity - Adjusted spacing in various Chinese translations to enhance readability and maintain consistency across the locale file. - Updated multiple user-facing strings to ensure proper formatting and alignment with localization standards. * feat(calendar): add CalendarDropdown component for enhanced dropdown functionality - Introduced a new CalendarDropdown component to improve user interaction with dropdown selections in the calendar. - Implemented state management for dropdown visibility and selection handling, enhancing the overall user experience. - Updated styling for dropdown elements to ensure consistency and better alignment with the UI design. * fix(balance-query-dialog): handle null currentRow and improve usage query logic - Updated the BalanceQueryDialog component to safely access currentRow properties using optional chaining. - Added a check to ensure currentRow is not null before proceeding with usage queries, preventing potential runtime errors. - Refactored the handleQueryCodexUsage function to use a local variable for currentRow, enhancing code clarity. * feat(i18n): add new translations for batch creation and channel updates - Added new translation strings for batch creation instructions across multiple languages, enhancing user guidance. - Included translations for the "Update Channel" prompt to improve clarity in channel configuration settings. - Ensured consistency in terminology across locale files for better user experience. * feat(channel-mutate-drawer): improve API key input handling and update translations - Refactored the API key input logic in the ChannelMutateDrawer component to enhance readability and maintainability. - Added new placeholder translations for batch creation and existing key prompts in multiple languages, improving user guidance. - Ensured consistency in translation strings across locale files for better user experience. * feat(fetch-models-dialog): implement sorting for model categories - Added a new function to sort model categories alphabetically, placing 'Other' at the end for easier navigation. - Updated the rendering logic in the FetchModelsDialog component to utilize the new sorting function for both new and existing models, enhancing user experience. * refactor(wallet-stats-card): standardize props usage and improve layout consistency Standardizes props usage and improves layout consistency in wallet stats card Refactors the wallet stats card component to: - Use props directly instead of destructuring for consistency - Add min-w-0 to prevent content overflow - Adjust text sizing with break-all for proper wrapping - Implement responsive font sizes (3xl on mobile, 4xl on larger screens) - Improve leading and tracking for better readability Refactor wallet stats card for consistency and layout Standardizes props usage and improves layout consistency in wallet stats card - Uses props directly instead of destructuring for consistency - Adds min-w-0 to prevent content overflow - Adjusts text sizing with break-all for proper wrapping - Implements responsive font sizes (3xl on mobile, 4xl on larger screens) - Improves leading and tracking for better readability * feat(web): add subscription management and admin settings UI * feat(web): add subscription management and admin settings UI - Add subscription management module (plans, pricing, toggle status, and related dialogs/tables with Stripe/Creem integration notes) - Add channel affinity (rules and cache stats), Waffo integration, performance, and Grok model sections to system settings, with extended types and section registry - Add status code mapping validation/risk warnings, upstream update hooks, and utilities for channels; add available models and sidebar module cards to user profile - Add chat2link route and useMinimumLoadingTime, useTableCompactMode shared hooks Made-with: Cursor * fix: remove duplicate GenerateOAuthCode and add missing TaskBulkUpdate - remove duplicate GenerateOAuthCode from github.go since oauth.go already has the generic version. - add model.TaskBulkUpdate for bulk update by upstream task_id strings, fixing task_video.go build failure. * feat(router): add chat2link and subscriptions routes - register /chat2link page route under authenticated layout. - register /subscriptions/ page route under authenticated layout. - update auto-generated routeTree type definitions and route mappings. * feat(docker): add development environment setup with Docker Compose - Introduced docker-compose.dev.yml for local development, including services for new-api, Redis, and PostgreSQL. - Created Dockerfile.dev for backend-only builds, optimizing the development workflow. - Updated makefile to include new commands for starting backend services and frontend development. * feat(web): complete i18n coverage for setup wizard and add language switcher - wrap all hardcoded English strings in setup-wizard, database-step, usage-mode-step, and complete-step with t() calls, covering step titles, descriptions, form validation messages, and fallback strings. - add LanguageSwitcher component to the top-right corner of the setup page so users can switch language during initial setup. - register 25 dynamic i18n keys in static-keys.ts and provide full translations for zh/en/ja/fr/ru/vi. * feat(i18n): internationalize default version text in workspace-switcher - remove hardcoded 'Unknown version' default, use t('Unknown version') for i18n fallback - add "Unknown version" translation entries across all 6 locale files (zh/en/fr/ru/ja/vi) * feat(i18n): add full i18n coverage for channel-affinity settings page - replace Chinese t() keys with English keys across three channel-affinity components to align with new frontend i18n conventions. - add 51 translation entries to all 6 locale files (en/zh/ja/fr/ru/vi) covering main page, rule editor, and cache stats dialog. - register section-registry dynamic keys in static-keys.ts. * feat(i18n): add full i18n coverage for Waffo payment settings page - replace Chinese i18n keys with English keys in waffo-settings-section.tsx for consistency. - wrap previously hardcoded labels (Pay Method Type / Pay Method Name) with t(). - add 26 Waffo-related translation entries across all 6 locale files (en/zh/fr/ru/ja/vi). * feat(i18n): add missing translations for global model settings page - add all 6 locale translations for 3 missing t() keys in global-settings-card. - register dynamically used 'Grok' key in static-keys.ts for i18n scanner coverage. * feat(i18n): add full i18n coverage for Grok model settings page - add translations in all 6 locales (en/zh/fr/ja/ru/vi) for grok-settings-card t() calls. - cover violation fee toggle, amount input, and official docs link labels. - include section-registry descriptionKey translation entries. * feat(i18n): add full i18n coverage for performance settings page - migrate all t() keys from Chinese to English to align with project conventions. - add translations for all 6 locales (en/zh/ja/fr/ru/vi) covering disk cache, system monitoring, log management, and stats dashboard sections. - remove 71 obsolete Chinese-keyed entries from every locale file. * fix(i18n): add 116 missing English translation keys across all locales - scan all t() calls to identify English keys used in code but absent from locale files. - add translations for zh/en/fr/ja/ru/vi, keeping key sets and sort order consistent. - covers system-settings, channels, models, auth, wallet and other modules. * fix(i18n): add missing translations for log cleanup quick-select and confirm dialog - wrap quick-select button labels (24 hours ago / 7 days ago / 30 days ago) with t(). - replace hardcoded English strings in purge confirm dialog with t() calls and date interpolation. - add 5 new translation keys across all 6 locale files (zh/en/fr/ja/ru/vi). * refactor(web): unify all time display with dayjs formatting - replace all toLocaleString/toLocaleDateString/toLocaleTimeString and manual padStart concatenation with dayjs.format(). - standardize output: datetime as YYYY-MM-DD HH:mm:ss, date as YYYY-MM-DD, time as HH:mm:ss. - add formatDateTimeStr, formatDateStr, formatTimeStr dayjs-based helpers in lib/format.ts. - update 12 files across core utils and feature components. * refactor(web): replace native datetime-local input with DateTimePicker in announcements - swap browser-native datetime-local for the project's DateTimePicker component to match the UI used in log cleanup and other pages. - convert between Date objects and ISO strings to bridge the form's string-based schema. * refactor(web): replace native HTML elements with design system components - replace ~35 native <button> with <Button> across pricing, profile, channels modules - replace native <input>/<textarea>/<label> with <Input>/<Textarea>/<Label> for consistent form styling - replace native <table> with <Table> components, <details>/<summary> with <Collapsible> - replace decorative <hr> with <Separator> to ensure global UI consistency * refactor(web): enhance profile components with design system consistency - update ProfileSecurityCard to use buttons for security actions, improving accessibility and styling. - modify AccountBindingsTab layout to a grid for better responsiveness and visual alignment. - refactor NotificationTab to utilize icons for notification methods, enhancing user experience and clarity. * fix(i18n): complete i18n coverage for profile page components - wrap passkey card status badges (enabled/disabled, backup state) and last-used text with t() - fix hardcoded button labels in security dialogs (change password, access token, delete account) - internationalize all 2FA dialog strings (setup, disable, backup codes) - fix email bind dialog description and button state text missing i18n - wrap remaining hardcoded strings in notification tab and checkin calendar - add all missing translation entries to zh.json and en.json * fix(i18n): enhance error messages with translations for deployment access and settings - wrap connection error messages in DeploymentAccessGuard and IoNetDeploymentSettingsSection with t() for internationalization. - add missing translation key for "io.net model deployment is not enabled or api key missing" in all locale files (en, fr, ja, ru, vi, zh). * 🧹 chore(web): resolve all ESLint errors and warnings Align the Vite/React frontend with the current ESLint flat config and React Compiler–related rules by fixing violations instead of broad suppression where practical. - Replace `any` with concrete types (`unknown`, `Record<string, unknown>`, domain types) where upstream/API shapes allow - Fix duplicate imports, unused bindings, `no-console`, and empty blocks - Address react-hooks issues: reorder declarations, memoize unstable callbacks (`useCallback`), extend dependency arrays, and use targeted disables only where sync-from-props in `useEffect` is intentional - Refactor `motion.create` usage in ai-elements shimmer to avoid creating components during render (static-components) - Stabilize TanStack Query/Mutation hook usage (query keys, `mutate` in deps) and add narrowly scoped rule disables where the linter conflicts with library patterns - Disable `react-hooks/incompatible-library` in ESLint config for TanStack Table / RHF false positives - Add file-level `react-refresh/only-export-components` disables for registry/provider/column modules that intentionally mix exports `bun lint` completes with 0 errors and 0 warnings. * ✨ feat(web): add subscription management to sidebar and align drawer with project conventions - Register "Subscription Management" nav item in the admin sidebar group with CreditCard icon pointing to /subscriptions - Add subscription module to sidebar config defaults and URL mapping so it integrates with the admin sidebar modules toggle in system settings - Add subscription entry to sidebar-modules-section moduleMeta for the maintenance settings UI - Refactor SubscriptionsMutateDrawer to follow the same patterns used by users, redemption-codes, and other mutate drawers: - Use shadcn Form/FormField/FormItem/FormControl/FormLabel/FormMessage instead of raw register() + Label + manual error display - Move SheetFooter outside the form with form attribute association - Use SheetClose for the cancel button - Reset form state on drawer close - Align SheetContent width (sm:max-w-[600px]) and spacing conventions * ✨ feat(web): overhaul UI/UX with Vercel Geist design alignment Refactor the entire frontend UI/UX to align with Vercel/OpenAI design principles, covering layout, animations, skeleton loading, and overall visual polish. Motion & Page Transitions: - Add centralized motion system (lib/motion.ts) with Vercel-style transition presets, stagger variants for tables, cards, and sidebars - Implement AnimatedOutlet for route-level page enter animations using TanStack Router pathname keying - Add PageTransition, StaggerContainer, StaggerItem, CardStagger, and TableStagger wrapper components for progressive reveal effects Skeleton Loading — Vercel Geist Style: - Replace shadcn default `animate-pulse` with Geist-style shimmer sweep animation (linear-gradient + background-position keyframes) - Add `--skeleton-base` / `--skeleton-highlight` CSS variables tuned for both light and dark themes with neutral oklch tones - Override auto-skeleton-react inline styles via CSS to unify all skeleton elements under the same shimmer effect - Update TableSkeleton with varied column widths for a natural feel - Add ContentSkeleton and QuerySkeleton wrappers for auto-skeleton integration with React Query error/loading states - Respect prefers-reduced-motion: disable shimmer for accessibility Layout & Sidebar: - Upgrade sidebar expand/collapse transitions to cubic-bezier easing - Add hover micro-interactions (background-color, color, transform) to sidebar menu buttons with smooth 150ms transitions - Fix oklch color compatibility in sidebar outline variant - Integrate AnimatedOutlet into AuthenticatedLayout for unified route-level animations Theme & CSS: - Streamline theme.css with cleaner oklch color definitions - Add CSS table row stagger-in animations with nth-child delays - Fix hover-scrollbar color bug (hsl → color-mix for oklch compat) - Add content-auto utility for long list rendering optimization Cleanup: - Remove deprecated skeleton-wrapper.tsx - Remove unused imports and dead code across components - Add empty-state, error-state, and loading-state utility components * 🐛 fix(docker): track bun.lock to fix Docker build failure Remove `web/bun.lock` from `.gitignore` so the lock file is committed to version control. The Dockerfile `COPY web/bun.lock .` instruction requires this file to be present in the build context, and ignoring it caused the build to fail with a "not found" error. * ⬆️ chore(web): upgrade dependencies and fix all type/lint errors Upgrade all frontend dependencies to latest stable versions: - lucide-react 0.562 → 1.7 (major: brand icons removed) - shiki 3.x → 4.x, eslint 9.x → 10.x, knip 5.x → 6.x - @rsbuild/core 1.3 → 1.7, @types/node 24 → 25 - tailwindcss/postcss 4.1 → 4.2, motion 12.25 → 12.38 - @tanstack/react-query 5.90 → 5.95, zod 4.3.5 → 4.3.6 - react 19.2.3 → 19.2.4, axios 1.13.2 → 1.13.6 - prettier 3.7 → 3.8, typescript-eslint 8.52 → 8.57 - Add missing optional deps: @xyflow/react, embla-carousel-react Resolve all TypeScript compilation errors introduced by upgrades: - Replace lucide-react brand icons (Github) with react-icons/si - Fix react-hook-form Control/Resolver generics for zod v4 - Fix Record<string, unknown> type constraints across API utils - Fix axios interceptor return types in lib/api.ts - Add type assertions for useSettings/useStatus hook returns - Resolve Badge variant, spread type, and route path mismatches Resolve all ESLint 10 errors: - preserve-caught-error: attach cause to re-thrown errors - no-useless-assignment: refactor redundant variable assignments - prefer-as-const: use `as const` over literal type assertions - no-unused-vars: prefix type-only schemas with underscore Update tsconfig lib from ES2020 to ES2022 for Error.cause support. * 🐛 fix(web): stop pricing model row from centering its content Wrapping the row in shadcn <Button variant='ghost'> inherits `justify-center`, and the inner flex container had no width, so `justify-between` collapsed and the row appeared centered. * feat: add Waffo payment integration and related UI components - Introduced Waffo payment method with support for custom icons and settings. - Updated payment settings section to include Waffo settings. - Added Waffo payment request handling in the wallet API. - Enhanced wallet recharge form to support Waffo payment methods. - Implemented hooks for Waffo payment processing. - Updated localization files for new Waffo-related strings. - Added new payment type and icon for Waffo in constants and UI components. - Refactored topup info handling to include Waffo payment methods and configurations. * feat(profile): add admin-only upstream model update notification setting * fix(web): make sidebar module user settings actually take effect Previously, saving sidebar module preferences in profile had no effect because the client ignored user-level sidebar_modules entirely. This fix wires user config into useSidebarConfig so the sidebar updates immediately without a page refresh. Changes: - Add UserPermissions type with sidebar_settings/sidebar_modules fields - Refactor useSidebarConfig to merge admin × user config with AND logic - Sync sidebar_modules to auth store on save for immediate UI updates - Conditionally render SidebarModulesCard based on user permissions - Treat null/empty user config as "do not narrow" for legacy users * feat(web): add custom OAuth provider CRUD and login button support Migrate custom OAuth from v1 to v2: - Admin CRUD UI with provider table, form dialog, preset templates, and OIDC discovery - Login page renders dynamic buttons for custom OAuth providers - Fix account bindings display showing "Not bound" text when already bound * feat(web): add ServerAddress, SMTPForceAuthLogin, CreateCacheRatio and group special usable settings Migrate missing v1 system settings to v2: - ServerAddress input in General > System Information - SMTPForceAuthLogin toggle in Integrations > Email - CreateCacheRatio JSON editor in Models > Ratio - Group special usable group rules editor in Models > Ratio * feat(web): wire user subscriptions dialog to users table row actions The UserSubscriptionsDialog component already existed but had no entry point in the users table dropdown menu. Add "Manage Subscriptions" menu item. * chore(web): update i18n translations for new settings and custom OAuth * 💎 refactor(web): redesign pricing page with flat, typography-driven layout * 🌐 chore(i18n): complete missing translations and normalize project config - Add 425+ missing translations across fr, ja, ru, zh, vi locales for subscription management, sidebar navigation, Grok settings, upstream model updates, pricing page, and other UI components - Add 37 missing i18n keys used in t() calls but absent from locale files (pricing filters, display options, audio/cache labels, etc.) - Fix stale tech stack info in CLAUDE.md, AGENTS.md, and project.mdc: React 18 → 19, Vite → Rsbuild, Semi Design → Radix UI + Tailwind - Fix i18n key format description: "Chinese source strings" → English - Deduplicate .cursor/rules/project.mdc to avoid triple-loading the same rules already present in root CLAUDE.md and AGENTS.md - Add i18n-translate Cursor skill for repeatable translation workflow * 🎨 refactor(web): redesign dashboard with flat, typography-driven layout Replace Card-based dashboard components with a flat, border-driven design system consistent with the pricing page, following the ui-style.mdc conventions. Overview section: - StatCard: replace Card wrapper with flat flex layout, monospace tabular values, uppercase tracking-wider labels, layered opacity hierarchy - PanelWrapper: replace Card/CardHeader/CardContent with rounded-lg border container and border-b header - SummaryCards: merge three stat cards into a single bordered container with divide-x separators; decouple border from stagger animation to prevent border deformation during entrance transitions - ApiInfoPanel/Item: full-width list rows with border-b separators, monospace route names, layered opacity for URLs and descriptions - AnnouncementsPanel: native button rows with hover:bg-muted/40, i18n for "Click for details" hint - FAQPanel: lighter border-border/60 accordion dividers, muted answer text - UptimePanel: uppercase tracking-wider group headers with bg-muted/30 background, monospace uptime percentages, fine-grained border opacity Models section: - LogStatCards: replace Card with rounded-lg border + divide-x grid, fix react-hooks/exhaustive-deps by destructuring props before useEffect - ModelCharts: replace Card+Tabs with bordered container + custom segmented control matching ui-style spec - Suspense fallbacks: match new flat skeleton layout with accurate column structure Animation: - Wrap models section in FadeIn with staggered delay - Keep CardStagger for overview panel grid (each panel has own border) Other: - Add ui-style.mdc cursor rule documenting the design language - Disable react-refresh/only-export-components for src/routes/** in eslint config (TanStack Router route files always export Route objects) - Fix zh.json: "Token-based" translation "基于令牌的" → "按量计费" * ✨ refactor(web): adopt flat dot-and-text design for all status badges Replace the bordered/colored-background StatusBadge and Badge components across the entire frontend with a minimal flat design: a small colored dot followed by colored text, eliminating visual noise from heavy borders, backgrounds, and rounded pill shapes. Key changes: - Redesign StatusBadge to use dot + text instead of bordered pill style, removing cva-based background/border variants in favor of exported dotColorMap and textColorMap lookup tables - Add children prop support to StatusBadge for flexible content rendering alongside the existing label prop - Migrate all Badge usages (except pricing page) to StatusBadge with appropriate variant mappings (default→info, secondary→neutral, outline→neutral, destructive→danger) - Consolidate adjacent multi-badge groups into single-dot layouts with dot separators (·) to reduce visual clutter in: - Channel balance columns (used + remaining) - Channel type column (type + IO.NET indicator) - User invite info column (invited + revenue + inviter) - Usage log stats bar (usage + RPM + TPM) - Usage log time/FRT column (time + FRT + stream status) - Subscription plan counts (active + expired) - Channel affinity scope/regex/key-source columns - Prefill group card headers (type + ID) - Export dotColorMap and textColorMap for direct use in custom inline layouts that need consistent status colors without the full component * ✨ refactor(web): redesign public layout and landing page with modern UI Overhaul the public-facing layout, header, and homepage to deliver a polished, animation-rich landing experience inspired by contemporary SaaS design patterns. Header: - Replace sticky header with fixed floating navbar that compacts into a pill-shaped glass-morphism bar on scroll (backdrop-blur + ring) - Add smooth 700ms cubic-bezier transitions for scroll-based shrinking - Build full-screen mobile menu overlay with staggered entry animations - Remove background color from logo container, show logo image directly Homepage sections: - Hero: gradient text title, radial gradient + grid pattern background, interactive terminal demo showcasing API request/response - Terminal demo: auto-cycles through gpt-4o, claude-sonnet-4-20250514, gemini-2.5-pro, deepseek-chat with smooth cross-fade transitions, clickable model badges, dual theme support (light/dark), fixed height - Stats: animated counters driven by IntersectionObserver with cubic-bezier easing, supports integer and decimal modes - Features: Bento grid layout with gap-px border technique, each card includes contextual visuals (model list, security badge, workflow) - How It Works: new three-step process section (Configure → Connect → Monitor) with connecting gradient line and numbered badges - CTA: gradient mesh background with scale-in scroll animation - Footer: streamlined brand column + link columns layout New components: - AnimateInView: IntersectionObserver-based scroll animation component supporting fade-up, fade-in, scale-in, fade-left, fade-right - HeroTerminalDemo: themed terminal with model carousel and live request/response preview CSS: - Add landing page scroll-triggered keyframe animations - Add terminal demo animations (blink cursor, spinner, pulse indicator) - Respect prefers-reduced-motion throughout i18n: - Add 17 new translation keys across all 6 locales (en/zh/fr/ja/ru/vi) * ✨ feat(web): align usage logs and channels with legacy UI Usage logs - Show Refund (type 6) in detail dialog and hide conversion chain for refunds - Sync filter dialog state from URL for model, token, group, username, and requestId Channels - Support optional stream flag in channel test API, actions, and test dialog - Show upstream model update badges (+added / -removed) on fetchable channel types - Add form fields and drawer toggles for upstream model update check and auto-sync - Persist upstream model update flags in channel settings JSON for fetchable types i18n - Add locale strings for upstream model update UI (en, zh, fr, ja, ru, vi) * 🐛 fix(web): prevent transient vertical scrollbar on tables during animations Add overflow-y-clip to the shared Table container (data-slot=table-container) alongside overflow-x-auto. Setting overflow-x to auto implicitly pairs with overflow-y: auto in browsers, which made the table shell briefly show a vertical scrollbar during route enter motion (y/blur) and table row stagger. Remove the redundant descendant selector workaround from the model pricing GroupPricingSection; behavior is now covered globally by the Table component. * 🏗️ refactor(web): redesign console layout with fixed header, scrollable content, and pinned footer Overhaul the authenticated console layout to match the OpenAI dashboard pattern: header and page title bar stay fixed at the top, only the content area scrolls, and table pagination is pinned to the bottom. Layout architecture: - Lock SidebarInset to full viewport height (h-svh) so all inner regions are controlled by flexbox instead of document scroll - Convert Main from a generic div to a semantic <main> flex container with overflow-hidden, removing the legacy `fixed` prop and `data-layout` attribute - Strip scroll-shadow logic and `fixed` prop from Header/AppHeader; the header is now naturally fixed as a shrink-0 flex child - Restructure SectionPageLayout into three flex regions: a shrink-0 title bar, a flex-1 overflow-auto content area, and a shrink-0 footer portal target with empty:hidden - Add min-h-0 to AnimatedOutlet wrappers to prevent flex overflow Footer portal system: - Introduce PageFooterProvider / PageFooterPortal (React Context + createPortal) so deeply nested table components can render their DataTablePagination into the fixed footer without prop drilling - Migrate all 8 data tables (api-keys, channels, users, models, deployments, usage-logs, subscriptions, redemption-codes) to use PageFooterPortal for pagination Page-level fixes: - Profile: wrap content in a scrollable flex child with proper padding - SystemSettings: remove overflow-auto from wrapper to avoid nested scrollbars (sub-pages manage their own scroll) - Playground / Error pages: remove obsolete `fixed` props API keys UX improvement: - Replace inline key show/hide toggle with a Popover-based reveal, removing toggleKeyVisibility and keyVisibility state from the provider context Cleanup: - Remove dead CSS rule for body:has([data-layout='fixed']) - Remove unused `fixed` prop from Header, AppHeader, and Main types - Export PageFooterPortal from layout barrel file * 💅 refactor(web): polish table UI consistency and add pagination transitions - Standardize primary action buttons (Create, Add, Search) to size="sm" across all pages for visual consistency with channels and models - Redesign NumericSpinnerInput with minimal inline style: plain text by default, hover-revealed +/- buttons, click-to-edit — replacing the clunky bordered input with stacked chevron arrows - Fix vertical scrollbar in channels group column by replacing overflow-x-auto with overflow-hidden (redundant with +N collapse) - Simplify API keys group column: replace colorful StatusBadge pairs with clean typography using opacity hierarchy and dot separators - Move API key copy loading indicator from key text to the copy button itself, eliminating layout shift during key resolution - Reduce page title from text-2xl to text-lg and subtitle to text-sm in SectionPageLayout for a more compact header - Add smooth opacity transition (duration-150) on all 7 server-paginated tables during background data fetches (isFetching && !isLoading), with pointer-events-none to prevent interaction during loading - Constrain usage logs Details column width (size: 200, maxSize: 220) * 🐛 fix(web): restore missing padding on system settings content The console layout refactor in d2150469 moved padding ownership from Main onto each route, but SystemSettings was missed — its Outlet wrapper had no padding, so the content area sat flush against the sidebar and top nav. Add `px-4 pt-6 pb-4` to match the vertical rhythm used by SectionPageLayout and the Profile page. * 📱 refactor(web): standardize mobile responsive layout across all table pages Unify mobile experience for all data table pages (channels, keys, models, deployments, usage-logs, users, redemption-codes, subscriptions) with a consistent layout pattern and cleaner header area. DataTableToolbar: - Redesign mobile layout: full-width search input + collapsible filter toggle button with active filter count badge - Filters, additional search, and reset button collapse into an expandable section on mobile, keeping the default view compact - Desktop layout remains unchanged SectionPageLayout: - Tighten mobile spacing (padding, gaps) for higher content density - Scale down title (text-base) and description (text-xs) on mobile - Shrink action button gaps on small screens ChannelsPrimaryButtons: - Move Tag Mode and Sort by ID toggles into the "More" dropdown on mobile (via DropdownMenuCheckboxItem), freeing header space - Desktop toggle switches remain visible outside the dropdown MobileCardList (shared component): - Compact list-item layout with title + badge header row and side-by-side key fields, replacing individual card components - Structured (CompactRow) and fallback (FallbackRow) rendering modes driven by column meta (mobileTitle, mobileBadge, mobileHidden) New MobileCardList integration: - Users table: username as title, status as badge; hide id, display_name, invite_info on mobile - Redemptions table: name as title, status as badge; hide id, created_time, expired_time, used_user_id on mobile - Subscriptions table: plan title as title, enabled as badge; hide id, sort_order, reset, payment, total_amount, upgrade_group on mobile Column meta updates: - Add mobileTitle/mobileBadge/mobileHidden meta across all 8 table column definitions for consistent mobile field prioritization Minor fixes: - Hide Subscriptions Stripe/Creem alert on mobile - Disable card hover animations on mobile via CSS media query * 🐛 fix(web): sync favicon with custom system logo Favicon stayed at the hardcoded /logo.png while document.title already followed system_name, leaving tab icon and site branding out of sync. Apply the logo as favicon from localStorage cache on startup, refresh from getStatus(), and re-apply when useSystemConfig finishes preloading. Extract applyFaviconToDom helper into lib/dom-utils with idempotent guard to avoid redundant DOM writes. * ✨ feat(web): add channel affinity rule templates and CreateCacheRatio visual editing Port missing features from legacy frontend (b8650b9 merge) to the new React frontend: - Add Codex CLI and Claude CLI channel affinity rule templates with header passthrough presets (pass_headers operations for Originator, Session_id, X-Codex-*, X-Stainless-*, Anthropic-*, etc.) - Introduce "Add Rule" dropdown menu with blank, Codex CLI, and Claude CLI template options in the channel affinity settings page - Add "Fill Templates" button to batch-append both CLI templates with duplicate name resolution and confirmation dialog - Support templateKey prop in RuleEditorDialog to pre-fill form fields from selected template, auto-expanding advanced settings when a param_override_template is present - Add CreateCacheRatio support to the model ratio visual editor, edit dialog, and form — previously only editable in JSON mode, now fully integrated into the visual table column, add/edit dialog fields, and save/delete handlers * 🐛 fix(web): fix content-type detection bugs in About and Home pages - Fix About page URL detection: replace naive `startsWith('https://')` with proper `new URL()` validation to support both http and https, and handle untrimmed input that previously caused silent misdetection - Fix About page HTML detection: remove overly broad `startsWith('<')` and `endsWith('>')` checks that could misclassify Markdown or XML content; align with LegalDocument's regex-only `isLikelyHtml` approach - Fix Home page URL detection: same `startsWith('https://')` bug, replaced with `new URL()` protocol validation - Refactor About page to use early-return pattern instead of deeply nested ternary expressions for better readability - Replace About loading spinner with Skeleton placeholder consistent with LegalDocument - Add `prose prose-neutral dark:prose-invert` typography classes to About HTML/Markdown rendering for proper dark mode support - Remove unused `Code` icon import from About page * ✨ feat(web): port missing features from legacy frontend and complete i18n Backport and enhance several features from the old frontend (web/old) that were missing or incomplete in the new React frontend: - Playground & channel test: parse structured JSON error responses from SSE streams and non-streaming API calls, extract error codes, and display actionable UI for `model_price_error` (admin settings link) - User management: replace local quota manipulation with atomic server-side quota adjustments (add/subtract/override) via dedicated API endpoint, making the quota field read-only in the edit drawer - Subscriptions: display next quota reset time for active subscriptions - Dashboard: limit model ranking chart to top 20 models with an "Other" bucket, add dimension tooltips with sorted values and totals to model call trend and user consumption trend charts - i18n: add 24 new translation keys across all 6 locales (en, zh, fr, ja, ru, vi) for the newly introduced UI elements and messages * 🎨 feat: add backend-configurable frontend theme switching (default/classic) Introduce runtime frontend theme switching so administrators can switch between the new frontend (Radix UI + Tailwind) and the classic frontend (Semi Design) from the settings page without restarting the server. Directory restructuring: - Move new frontend from web/ to web/default/ - Move classic frontend from web/old/ to web/classic/ - One-frontend-per-folder layout for extensibility Backend (injection pattern): - Add setting/system_setting/theme.go with GlobalConfig.Register("theme") so the DB key "theme.frontend" is handled automatically by handleConfigUpdate — no switch-case in updateOptionMap needed - Use atomic.Value in common.GetTheme()/SetTheme() for lock-free concurrent reads on the hot path (static file middleware) - Add themeAwareFileSystem that delegates to the correct embedded FS based on the current theme at request time - Embed both frontends into the binary via go:embed - Add controller validation for theme.frontend values - Expose theme in GET /api/status response Frontend settings UI: - New frontend: add "Frontend Theme" select in System Information section using Radix UI Select + react-hook-form + Zod validation - Classic frontend: add "前端主题" select in Personalization section using Semi Design Form.Select Build system: - Update Dockerfile with multi-stage builds for both frontends - Update Makefile with separate build targets for each frontend - Update GitHub Actions release workflow for dual frontend builds i18n: - New frontend: add translations for all 6 locales (en/zh/fr/ja/ru/vi) - Classic frontend: add translations for all 7 locales (en/zh-TW/ja/fr/ru/vi) - Fix zh "AI Proxy Library" → "AI 代理库" Documentation: - Update CLAUDE.md, AGENTS.md, .cursor/rules/project.mdc to reflect the new web/default/ and web/classic/ directory structure * ✨ feat(web): add allow_speed passthrough for Claude channels, fix multi-key index and inference_geo scope - Add `allow_speed` toggle for Anthropic (type 14) channels to control Claude inference speed mode passthrough, with full form schema, settings persistence, and UI switch - Fix `allow_inference_geo` to also apply to Anthropic (type 14) channels, not just OpenAI (type 1), matching the backend behavior for Claude data residency region control - Fix multi-key management dialog to display 1-based key indices instead of 0-based (#{key.index + 1}) - Fix TypeScript type error in section-registry by adding type assertion for theme.frontend enum - Add i18n translations for all new keys across 6 locales (en, zh, fr, ja, ru, vi) * 🧹 chore: clean up editor configs, consolidate agent skills, and set classic as default theme - Add .cursor/ to .gitignore and remove tracked editor config files (.cursor/rules/, .cursor/skills/) from version control - Consolidate .agents/skills/vercel-react-best-practices by keeping only the compiled AGENTS.md and removing redundant SKILL.md and 57 individual rule files under rules/ - Change default frontend theme from "default" to "classic" in both common/constants.go init and setting/system_setting/theme.go * feat: Frontend Tiered Pricing, Waffo Payments, and Rsbuild 2 Upgrade (#24) * feat(ui): add codex extra limits, key last used, and admin audit surfaces - codex usage dialog: render `additional_rate_limits` with `RateLimitGroupSection` and typed base/secondary window data. - api keys table: add "Last Used" column from `accessed_time`. - usage log details: show top-up audit and manage operator for admins; extend `LogOtherData` audit fields; broaden IP display; warn when legacy records lack audit data. - billing history: show user id badge for admins; add zh i18n for new strings. * feat(web): add dynamic pricing breakdown and Waffo Pancake payments - add billing-expr parsing and DynamicPricingBreakdown; surface tiered_expr in model list/details. - extend PricingModel with billing_mode, billing_expr, and pricing_version for backend parity. - add Waffo Pancake integration settings, amount/pay APIs, hook, and recharge flow wiring. - update payment confirm/recharge UI and Chinese locale strings. * feat(pricing): add tiered billing editor and tool price settings - introduce tier-expr and extend billing-expr (time/param conditions, combine/split helpers, editor utilities) for visual tiers and request rules. - support tiered_expr in model ratio dialog, form, and visual editor with billing_setting fields and default JSON placeholders. - add TieredPricingEditor and tool price settings UI plus i18n updates. * chore(web): bump rsbuild to v2 and align build config - upgrade @rsbuild/core, @rsbuild/plugin-react, and Rspack 2 transitives; bump TanStack Router packages and refresh bun.lock. - replace deprecated performance.chunkSplit with top-level splitChunks cache groups for react, radix, and tanstack vendors. - factor dev server proxy into devProxy; set legalComments to none in prod; enable performance.buildCache keyed by VITE_REACT_APP_VERSION. - TanStack Router plugin: enable autoCodeSplitting only in production for faster dev navigation and HMR. * fix(i18n): update translations for API keys and Waffo Pancake settings - Corrected translations for "API Private Key" and "Merchant ID is required" across multiple languages. - Added new translation for "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups." - Updated various existing translations to ensure consistency and clarity in user interface text. * refactor(code-block): simplify code highlighting and improve theme handling - Updated the highlightCode function to support dual themes in a single call, reducing complexity. - Removed unnecessary state management for dark theme HTML, streamlining the component. - Enhanced CSS for Shiki themes to ensure proper token color application in dark mode. * refactor(wallet): use isWaffoPancakePayment for pancake payment dispatch - replace the waffo_pancake string literal with the shared helper for consistency with use-payment and PAYMENT_TYPES. - centralize the value so a constant change does not require hunting for typos in multiple call sites. * fix(wallet): validate waffo pancake checkout url and safe open - allow only parseable http/https redirect targets from the backend, rejecting dangerous schemes. - pass noopener and noreferrer in window.open to reduce reverse tabnabbing. - show a toast and abort on invalid URLs; add i18n entries across locales. * fix(wallet): harden payment icon image URLs - add normalizeHttpIconUrl to allow only http(s) after resolution and reject userinfo in URLs. - set referrerPolicy, lazy loading, and async decode on the icon <img> to cut referrer leakage. - fall back to built-in icons on invalid URLs, same as when iconUrl is missing. * fix(pricing): label param() conditions as body param in dynamic pricing - non-header request rules map to `param()`, not query strings. - align with tiered pricing editor by using the existing `Body param` string. * fix(rsbuild): update legalComments handling in build config - Rely on Rsbuild's default legalComments setting in all modes to ensure compliance with open-source licensing requirements. - Clarified comments to explain the implications of omitting legalComments in production. * fix(i18n): correct billing and codex UI strings in locale files - restore ~83 en.json values to English (tool pricing, audit text, alipay label, etc.). - add proper fr/ru/vi/ja strings so those locales no longer copy zh. - change five locale files only; zh.json unchanged. * fix(i18n): update locale files for improved translations and sync report - Added missing translations and corrected existing strings in English, French, Japanese, Russian, Vietnamese, and Chinese locale files. - Updated the sync report to reflect zero missing translations across multiple locales. - Enhanced the untranslated count for Japanese locale to ensure completeness. - Changed the base locale from zh.json to en.json for better alignment. * chore(agents): add i18n-translate agent skill - add `.agents/skills/i18n-translate/SKILL.md` documenting locale layout under `web/default` and `bun run i18n:sync` usage. - capture a repeatable maintainer workflow with embedded script examples to find missing keys and untranslated values. - give agents a clear path to complete and verify translations across en, zh, fr, ja, ru, and vi. * feat(settings): hide frontend theme setting (#25) * feat(settings): hide frontend theme setting - add a local hidden feature flag with window.newapiUnlock support. - hide the frontend theme option by default and reveal it immediately after unlock. * feat(settings): support click unlock for frontend theme setting - add a shared hidden click unlock hook for repeated-click gated UI. - reveal the frontend theme option after triple-clicking the system information title. - preserve the Doubao API address ten-click unlock behavior and remove global unlock functions. * feat(sync 59337e9): Sync classic tiered billing, upstream price synchronization, and model management features to web/default (#26) * feat(skill): add classic-to-default-sync skill for auditing and syncing web/classic changes to web/default - Introduced a new skill to inspect a given commit's changes in web/classic and synchronize features and fixes to web/default. - Documented workflow steps for extracting diffs, mapping changes, triaging, implementing, and reporting on the synchronization process. - Emphasized quality standards and internationalization considerations for new user-visible strings. * feat(web/default): sync billing and model management features from classic - add `len` condition variable (total input context length); introduce BILLING_PRICING_VARS / BILLING_CONDITION_VARS to separate pricing vars from condition-only vars; fix tier condition regex to accept `len`. - rewrite upstream ratio sync components to support per-model grouped rows and new ratio types (create_cache, image, audio, billing_expr). - add LlmPromptHelper component; update tiered presets to use `len` for conditions; add GLM-4.5 Air, Doubao Seed 1.8, Qwen3 Omni Flash, and weekend-discount presets. - add created_at / last_login_at columns to users table; add "Removed Models" tab to FetchModelsDialog for mapping source keys not in the models list. - add extractMappingSourceModels helper; update dynamic-pricing-breakdown to use system currency settings; add 19 i18n keys across all locales. * ✨ feat(default): surface tiered billing in usage logs and gate Passkey ops behind 2FA Continues the classic-to-default sync (commit 1be6cdb) by porting the remaining audit-log, pricing-hint, and Passkey lifecycle features from web/classic to web/default using the default frontend's component patterns (Radix UI, Tailwind, shadcn-style dialogs). * feat(usage-logs): show tiered_expr breakdown and matched tier in details - Extend `LogOtherData` with `billing_mode`, `expr_b64`, and `matched_tier` fields populated by the backend for tiered logs. - Add `decodeBillingExprB64`, `resolveMatchedTier`, and `getTieredBillingSummary` helpers in `usage-logs/lib/format.ts` that centralise tiered-billing parsing on top of the canonical `parseTiersFromExpr` / `BILLING_PRICING_VARS` from the pricing feature, instead of duplicating the classic-frontend renderer. - Render `<DynamicPricingBreakdown>` inside the consume-log details dialog with the matched tier row highlighted in emerald and tagged "Matched"; suppress the legacy claude/audio/image cost rows when a tiered expression is in effect. - Surface per-tier prices and the matched tier label in log row segments and the billing breakdown table. * feat(pricing): show tier-count, time-based, and request-based hints in model list - Add `summarizeTieredExpr` that derives compact dynamic-pricing metadata (tier count + presence of time/request conditions) from a `tiered_expr` model, computed once per render via `useMemo`, so users can tell *what kind* of dynamic pricing applies before drilling into the model details. - Render the hints alongside the existing "Dynamic Pricing" badge in `<ModelRow>`. - Extend `<DynamicPricingBreakdown>` with a `matchedTierLabel` prop so the same component can be reused from the usage-log details dialog to highlight the tier that actually fired. * feat(profile): require Security Verification for Passkey register/remove - Wire `usePasskeyManagement` through `useSecureVerification` and `<SecureVerificationDialog>` in `<PasskeyCard>`. - Registration prompts for 2FA before issuing the Passkey credential (only when 2FA is already enabled — otherwise the browser-level Passkey prompt itself acts as proof of presence and we register directly). - Removal prompts for 2FA or Passkey, whichever the account has enabled, with informative toasts when neither method is available or the device lacks Passkey support. - Scope the dialog method set to the required factor so users cannot fall back to a weaker method, and propagate cancellation cleanly. * refactor: tighten upstream-ratio-sync and fix tier editor narrowing - Drop the unused `hasSynced` state and dead `getOrderedRatioTypes` / `isSelectableUpstreamValue` imports from `upstream-ratio-sync.tsx`. - In the cost estimator, narrow `BILLING_EXTRA_VARS` entries with a null-`field` guard to silence the type checker and make the "pricing variables only" contract explicit. - Apply Prettier-consistent formatting to the upstream-ratio-sync table/columns, channel mutate drawer, system info section, tier-expr, and wallet helpers (no behaviour change). * i18n: add 9 keys across en/zh/fr/ja/ru/vi - `{{count}} tiers`, `Billing Process`, `Matched`, `Matched Tier`, `Request-based`, `Security verification`, `Time-based`, plus the two new Passkey verification description strings. * 🔧 refactor(default): align upstream price sync, tiered billing, and fetch-models with classic 59337e9 Port and optimize the remaining web/classic features from commit 59337e9 to web/default, covering upstream price synchronization, tiered billing expressions, model fetching, and channel preset detection. Improve component architecture, memoization, and i18n coverage. Upstream Price Sync - Extend sync to all ratio fields: CacheRatio, CreateCacheRatio, ImageRatio, AudioRatio, AudioCompletionRatio in addition to ModelRatio / CompletionRatio / ModelPrice - Add tiered billing sync (billing_mode + billing_expr) with auto-pairing so selecting one upstream tier value populates the other from the same source - Bulk select / unselect per upstream column with indeterminate checkbox state reflecting partial selection - Confidence indicators warn when an upstream entry is heuristically derived - Conflict confirm dialog gains loading state and disables actions during sync - Default endpoint per channel: /api/pricing for official preset, /api/models.dev for the models.dev preset, /api/v1/models for OpenRouter, with the rest falling back to the global default - Rename tab label from "Upstream sync" to "Upstream price sync" for clarity Tiered Pricing Editor - Add `len` (full input length, including cache hits) as a tier-condition variable to avoid mis-routing when cache hits reduce `p` - When inserting a new tier, automatically convert the previous catch-all into a bounded tier with a `len <= X` upper bound - Cap each tier at 0~2 conditions and disable the add-condition button at the limit, with an Alert explaining the recommended `len` usage - Extend presets with Multimodal (img / img_o / ai / ao), Request rule (header/param matching), and Time-based (hour / weekday) entries - Embed an LLM prompt helper that copies a model-aware template for designing expressions with ChatGPT / Claude Fetch Models Dialog - Add a "Removed Models" tab listing models still in the local selection but no longer returned by the upstream listing - Exclude `model_mapping` source keys from the removed view so aliases never appear as missing entries - Force-remount tab content on tab switch via `key` prop to clear stale state - Switch count placeholders to `{{count}}` interpolation across "Existing Models", "New Models", and "Removed Models" labels Channel Selector & Constants - Recognize the models.dev preset (id, base_url, name) alongside the existing official-channel preset detection - Add MODELS_DEV_PRESET_* and OPENROUTER_* constants and reorder ENDPOINT_OPTIONS so `pricing` is preferred over `ratio_config` - Expose the new ratio types in RATIO_TYPE_OPTIONS for the sync filter Types - Add optional `type` field to UpstreamChannel for endpoint inference - Extend RatioType union with create_cache_ratio, image_ratio, audio_ratio, audio_completion_ratio, billing_mode, and billing_expr Code Quality & Performance - Extract upstream-ratio-sync-helpers.ts to host shared types (RatioDifferenceEntry, ModelRow, ResolutionsMap), field ordering (RATIO_SYNC_FIELDS, SYNC_FIELD_ORDER, NUMERIC_SYNC_FIELDS), and selection logic (getPreferredSyncField, isSelectableUpstreamValue, getSyncFieldLabel) - Memoize the column definitions in useUpstreamRatioSyncColumns and pull the per-cell rendering into a renderUpstreamValue helper to remove inline IIFEs - Wrap handleBulkSelect / handleBulkUnselect in useCallback for stable refs; rename the misleading `_upstream` parameter to `upstream` - Convert parsedRatios from useCallback (returning a function) to useMemo (returning the value) and update all call sites to read it as a value - Memoize the channels list with useMemo so the endpoint-init effect no longer fires on every render due to a fresh `?? []` reference i18n - Add and translate new keys ("Upstream price sync", "Audio Ratio", "Audio Completion Ratio", "Cache Create Ratio", "Image Ratio", "Expression Billing", "Fixed Price", "{{n}} model(s) selected", tier guidance, etc.) across en, zh, fr, ja, ru, vi - Fix truncated keys ("Existing Models (", "New Models (", "Removed Models (") to proper {{count}} interpolated forms in every locale - bun run i18n:sync reports 0 missing and 0 extra keys for every locale Verification - bun run typecheck: pass - bun run lint: pass - bun run i18n:sync: pass (0 missing / 0 extras across all locales) * 🐛 fix(default): port classic 73e5557 tiered-billing fixes and dedupe Title-Case ratio i18n keys Sync the web/classic frontend fixes from upstream merge 73e5557 to web/default, and clean up duplicated Title-Case ratio labels in the upstream sync UI that were shadowing the canonical sentence-case i18n keys. Cache-token filter for tiered model price (port of9f8a4ec05) - The matched-tier breakdown shown in the usage-log details dialog and in the log table previously listed every cache-related price (Cache Read, Cache Write, Cache Write 1h) regardless of whether the request actually consumed cache tokens. - `getTieredBillingSummary` in `usage-logs/lib/format.ts` now skips `cache`-group vars when none of `cache_tokens`, `cache_creation_tokens`, `cache_creation_tokens_5m`, or `cache_creation_tokens_1h` are positive, mirroring the classic `renderTieredModelPrice` / `renderTieredModelPriceSimple` logic. - Extract `hasAnyCacheTokens(other)` as an exported helper so the predicate is defined once. - Add a `hideCacheColumns?: boolean` prop to `DynamicPricingBreakdown` and wire it up from the log details dialog so the full tier table hides cache columns under the same condition. `model-details.tsx` keeps the default (show all configured prices), since that view represents the model's pricing structure rather than a specific call. `tiered_expr` ratio/price fallback during sync delays (port ofbee339d27) - When saving a model in tiered-expression mode, the visual editor used to delete every ratio/price map entry for the model and only write `billing_setting.billing_mode` / `billing_setting.billing_expr`. In multi-instance deployments, instances that had not yet observed the billing_setting update fell back to ratios that no longer existed, breaking pricing. - `model-ratio-dialog.tsx`: `handleSubmit` always passes every form field (`price`, `ratio`, `cacheRatio`, `createCacheRatio`, `completionRatio`, `imageRatio`, `audioRatio`, `audioCompletionRatio`) into the data object regardless of `pricingMode`, so a switch from per-token to tiered_expr no longer drops the previously entered ratios. - `model-ratio-visual-editor.tsx`: - The row builder now also surfaces ratio/price values for `tiered_expr` rows, so they survive the edit-and-save round trip and the next save. - `handleSave` factors out a `setIfPresent` helper and persists ratio/price entries for `tiered_expr` models alongside billing_mode / billing_expr. These act purely as fallback because the backend's `ModelPriceHelper` checks `billing_mode` first. - Cell rendering mutes ratio/price values whenever the row is `tiered_expr` (in addition to the existing per-request muting), making it visually clear the values are fallback, not the active pricing source. i18n: dedupe Title-Case ratio labels in upstream sync - `upstream-ratio-sync` `RATIO_TYPE_OPTIONS` previously used Title-Case labels (`Model Ratio`, `Cache Ratio`, `Audio Completion Ratio`, …) that were rendered through `t()` but never existed as canonical keys in the catalogue. The form-field side has used sentence-case (`Model ratio`, `Cache ratio`, …) for some time, leaving two parallel translation entries per ratio type and causing the upstream sync UI to fall back to the English source string in zh/ja/ru/fr/vi. - Rewrite `RATIO_TYPE_OPTIONS` in `system-settings/models/constants.ts` and the conflict-detection labels in `upstream-ratio-sync.tsx` to reuse the sentence-case keys. - Drop the duplicate Title-Case entries from every locale and promote the better translations onto the surviving sentence-case keys (e.g. zh `Image ratio` keeps "图片倍率", `Audio completion ratio` keeps "音频补全倍率"). - Add a comment to `RATIO_TYPE_OPTIONS` warning future contributors not to switch back to Title Case without updating the catalogue. Note on backend fix4e93148d9- The backend portion of the merge (allocating a fresh map in `updateConfigFromMap` so removed keys are properly cleared) is already on HEAD; no additional change is needed. Verification - `bun run typecheck`: pass - `bun run lint`: pass - `bun run i18n:sync`: 0 missing / 0 extras across en / zh / fr / ja / ru / vi --------- Co-authored-by: Seefs <40468931+seefs001@users.noreply.github.com> Co-authored-by: Seefs <i@seefs.me> Co-authored-by: feitianbubu <feitianbubu@qq.com> Co-authored-by: Calcium-Ion <i@caion.me> Co-authored-by: Xyfacai <xyfacai@gmail.com> Co-authored-by: xiangsx <1984871009@qq.com> Co-authored-by: 郑伯涛 <351175318@qq.com> Co-authored-by: RedwindA <austinaosid@gmail.com> Co-authored-by: dean <1006393151@qq.com> Co-authored-by: QuentinHsu <xuquentinyang@gmail.com> Co-authored-by: Bliod <bliod@bliod.lan> Co-authored-by: Apple\Apple <zeraturing@foxmail.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
export type ActionsProps = ComponentProps<'div'>
|
||||
|
||||
export const Actions = ({ className, children, ...props }: ActionsProps) => (
|
||||
<div className={cn('flex items-center gap-1', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type ActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const Action = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
className,
|
||||
variant = 'ghost',
|
||||
size = 'sm',
|
||||
...props
|
||||
}: ActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground relative size-9 p-1.5',
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type='button'
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className='sr-only'>{label || tooltip}</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps, HTMLAttributes } from 'react'
|
||||
import { type LucideIcon, XIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
export type ArtifactProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const Artifact = ({ className, ...props }: ArtifactProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const ArtifactHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted/50 flex items-center justify-between border-b px-4 py-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ArtifactCloseProps = ComponentProps<typeof Button>
|
||||
|
||||
export const ArtifactClose = ({
|
||||
className,
|
||||
children,
|
||||
size = 'sm',
|
||||
variant = 'ghost',
|
||||
...props
|
||||
}: ArtifactCloseProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground size-8 p-0',
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type='button'
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <XIcon className='size-4' />}
|
||||
<span className='sr-only'>{t('Close')}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>
|
||||
|
||||
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
|
||||
<p
|
||||
className={cn('text-foreground text-sm font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>
|
||||
|
||||
export const ArtifactDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactDescriptionProps) => (
|
||||
<p className={cn('text-muted-foreground text-sm', className)} {...props} />
|
||||
)
|
||||
|
||||
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const ArtifactActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactActionsProps) => (
|
||||
<div className={cn('flex items-center gap-1', className)} {...props} />
|
||||
)
|
||||
|
||||
export type ArtifactActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string
|
||||
label?: string
|
||||
icon?: LucideIcon
|
||||
}
|
||||
|
||||
export const ArtifactAction = ({
|
||||
tooltip,
|
||||
label,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
size = 'sm',
|
||||
variant = 'ghost',
|
||||
...props
|
||||
}: ArtifactActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground size-8 p-0',
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type='button'
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{Icon ? <Icon className='size-4' /> : children}
|
||||
<span className='sr-only'>{label || tooltip}</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const ArtifactContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactContentProps) => (
|
||||
<div className={cn('flex-1 overflow-auto p-4', className)} {...props} />
|
||||
)
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
type ReactElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { UIMessage } from 'ai'
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type BranchContextType = {
|
||||
currentBranch: number
|
||||
totalBranches: number
|
||||
goToPrevious: () => void
|
||||
goToNext: () => void
|
||||
branches: ReactElement[]
|
||||
setBranches: (branches: ReactElement[]) => void
|
||||
}
|
||||
|
||||
const BranchContext = createContext<BranchContextType | null>(null)
|
||||
|
||||
const useBranch = () => {
|
||||
const context = useContext(BranchContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Branch components must be used within Branch')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export type BranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number
|
||||
onBranchChange?: (branchIndex: number) => void
|
||||
}
|
||||
|
||||
export const Branch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: BranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch)
|
||||
const [branches, setBranches] = useState<ReactElement[]>([])
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch)
|
||||
onBranchChange?.(newBranch)
|
||||
}
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1
|
||||
handleBranchChange(newBranch)
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0
|
||||
handleBranchChange(newBranch)
|
||||
}
|
||||
|
||||
const contextValue: BranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
}
|
||||
|
||||
return (
|
||||
<BranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn('grid w-full gap-2 [&>div]:pb-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
</BranchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
|
||||
const { currentBranch, setBranches, branches } = useBranch()
|
||||
const childrenArray = useMemo(
|
||||
() => (Array.isArray(children) ? children : [children]),
|
||||
[children]
|
||||
)
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray)
|
||||
}
|
||||
}, [childrenArray, branches, setBranches])
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-2 overflow-hidden [&>div]:pb-0',
|
||||
index === currentBranch ? 'block' : 'hidden'
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage['role']
|
||||
}
|
||||
|
||||
export const BranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: BranchSelectorProps) => {
|
||||
const { totalBranches } = useBranch()
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 self-end px-10',
|
||||
from === 'assistant' ? 'justify-start' : 'justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type BranchPreviousProps = ComponentProps<typeof Button>
|
||||
|
||||
export const BranchPrevious = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BranchPreviousProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { goToPrevious, totalBranches } = useBranch()
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={t('Previous branch')}
|
||||
className={cn(
|
||||
'text-muted-foreground size-7 shrink-0 rounded-full transition-colors',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size='icon'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type BranchNextProps = ComponentProps<typeof Button>
|
||||
|
||||
export const BranchNext = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BranchNextProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { goToNext, totalBranches } = useBranch()
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={t('Next branch')}
|
||||
className={cn(
|
||||
'text-muted-foreground size-7 shrink-0 rounded-full transition-colors',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size='icon'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { currentBranch, totalBranches } = useBranch()
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-muted-foreground text-xs font-medium tabular-nums',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} {t('of')} {totalBranches}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Background, ReactFlow, type ReactFlowProps } from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { Controls } from './controls'
|
||||
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const Canvas = ({ children, ...props }: CanvasProps) => (
|
||||
<ReactFlow
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
fitView
|
||||
panOnDrag={false}
|
||||
panOnScroll
|
||||
selectionOnDrag={true}
|
||||
zoomOnDoubleClick={false}
|
||||
{...props}
|
||||
>
|
||||
<Background bgColor='var(--sidebar)' />
|
||||
<Controls />
|
||||
{children}
|
||||
</ReactFlow>
|
||||
)
|
||||
@@ -0,0 +1,233 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
memo,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useControllableState } from '@radix-ui/react-use-controllable-state'
|
||||
import {
|
||||
BrainIcon,
|
||||
ChevronDownIcon,
|
||||
DotIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
|
||||
type ChainOfThoughtContextValue = {
|
||||
isOpen: boolean
|
||||
setIsOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const useChainOfThought = () => {
|
||||
const context = useContext(ChainOfThoughtContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'ChainOfThought components must be used within ChainOfThought'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type ChainOfThoughtProps = ComponentProps<'div'> & {
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const ChainOfThought = memo(
|
||||
({
|
||||
className,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
})
|
||||
|
||||
const chainOfThoughtContext = useMemo(
|
||||
() => ({ isOpen, setIsOpen }),
|
||||
[isOpen, setIsOpen]
|
||||
)
|
||||
|
||||
return (
|
||||
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
|
||||
<div
|
||||
className={cn('not-prose max-w-prose space-y-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ChainOfThoughtContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ChainOfThoughtHeaderProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
>
|
||||
|
||||
export const ChainOfThoughtHeader = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
|
||||
const { isOpen, setIsOpen } = useChainOfThought()
|
||||
|
||||
return (
|
||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BrainIcon className='size-4' />
|
||||
<span className='flex-1 text-left'>
|
||||
{children ?? 'Chain of Thought'}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'size-4 transition-transform',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ChainOfThoughtStepProps = ComponentProps<'div'> & {
|
||||
icon?: LucideIcon
|
||||
label: string
|
||||
description?: string
|
||||
status?: 'complete' | 'active' | 'pending'
|
||||
}
|
||||
|
||||
export const ChainOfThoughtStep = memo(
|
||||
({
|
||||
className,
|
||||
icon: Icon = DotIcon,
|
||||
label,
|
||||
description,
|
||||
status = 'complete',
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps) => {
|
||||
const statusStyles = {
|
||||
complete: 'text-muted-foreground',
|
||||
active: 'text-foreground',
|
||||
pending: 'text-muted-foreground/50',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-2 text-sm',
|
||||
statusStyles[status],
|
||||
'fade-in-0 slide-in-from-top-2 animate-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className='relative mt-0.5'>
|
||||
<Icon className='size-4' />
|
||||
<div className='bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px' />
|
||||
</div>
|
||||
<div className='flex-1 space-y-2'>
|
||||
<div>{label}</div>
|
||||
{description && (
|
||||
<div className='text-muted-foreground text-xs'>{description}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ChainOfThoughtSearchResultsProps = ComponentProps<'div'>
|
||||
|
||||
export const ChainOfThoughtSearchResults = memo(
|
||||
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
|
||||
<div className={cn('flex items-center gap-2', className)} {...props} />
|
||||
)
|
||||
)
|
||||
|
||||
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>
|
||||
|
||||
export const ChainOfThoughtSearchResult = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
|
||||
<Badge
|
||||
className={cn('gap-1 px-2 py-0.5 text-xs font-normal', className)}
|
||||
variant='secondary'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
)
|
||||
|
||||
export type ChainOfThoughtContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>
|
||||
|
||||
export const ChainOfThoughtContent = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtContentProps) => {
|
||||
const { isOpen } = useChainOfThought()
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen}>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-2 space-y-3',
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ChainOfThoughtImageProps = ComponentProps<'div'> & {
|
||||
caption?: string
|
||||
}
|
||||
|
||||
export const ChainOfThoughtImage = memo(
|
||||
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
|
||||
<div className={cn('mt-2 space-y-2', className)} {...props}>
|
||||
<div className='bg-muted relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg p-3'>
|
||||
{children}
|
||||
</div>
|
||||
{caption && <p className='text-muted-foreground text-xs'>{caption}</p>}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
ChainOfThought.displayName = 'ChainOfThought'
|
||||
ChainOfThoughtHeader.displayName = 'ChainOfThoughtHeader'
|
||||
ChainOfThoughtStep.displayName = 'ChainOfThoughtStep'
|
||||
ChainOfThoughtSearchResults.displayName = 'ChainOfThoughtSearchResults'
|
||||
ChainOfThoughtSearchResult.displayName = 'ChainOfThoughtSearchResult'
|
||||
ChainOfThoughtContent.displayName = 'ChainOfThoughtContent'
|
||||
ChainOfThoughtImage.displayName = 'ChainOfThoughtImage'
|
||||
@@ -0,0 +1,170 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { Element } from 'hast'
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
import {
|
||||
type BundledLanguage,
|
||||
codeToHtml,
|
||||
type ShikiTransformer,
|
||||
} from 'shiki/bundle/web'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string
|
||||
language: BundledLanguage
|
||||
showLineNumbers?: boolean
|
||||
}
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string
|
||||
}
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: '',
|
||||
})
|
||||
|
||||
const lineNumberTransformer: ShikiTransformer = {
|
||||
name: 'line-numbers',
|
||||
line(node: Element, line: number) {
|
||||
node.children.unshift({
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: {
|
||||
className: [
|
||||
'inline-block',
|
||||
'min-w-10',
|
||||
'mr-4',
|
||||
'text-right',
|
||||
'select-none',
|
||||
'text-muted-foreground',
|
||||
],
|
||||
},
|
||||
children: [{ type: 'text', value: String(line) }],
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export async function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
showLineNumbers = false
|
||||
) {
|
||||
const transformers: ShikiTransformer[] = showLineNumbers
|
||||
? [lineNumberTransformer]
|
||||
: []
|
||||
|
||||
return codeToHtml(code, {
|
||||
lang: language,
|
||||
themes: {
|
||||
light: 'one-light',
|
||||
dark: 'one-dark-pro',
|
||||
},
|
||||
transformers,
|
||||
})
|
||||
}
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const [html, setHtml] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
highlightCode(code, language, showLineNumbers).then((next) => {
|
||||
if (!cancelled) {
|
||||
setHtml(next)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [code, language, showLineNumbers])
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
'group bg-background text-foreground relative w-full overflow-hidden rounded-md border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className='relative'>
|
||||
<div
|
||||
className='[&>pre]:bg-background! [&>pre]:text-foreground! overflow-hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:p-4 [&>pre]:text-sm'
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
{children && (
|
||||
<div className='absolute top-2 right-2 flex items-center gap-2'>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CodeBlockContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void
|
||||
onError?: (error: Error) => void
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const { code } = useContext(CodeBlockContext)
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === 'undefined' || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error('Clipboard API not available'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
setIsCopied(true)
|
||||
onCopy?.()
|
||||
setTimeout(() => setIsCopied(false), timeout)
|
||||
} catch (error) {
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={copyToClipboard}
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
} from 'react'
|
||||
import type { ToolUIPart } from 'ai'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// Workaround for missing types in 'ai' package
|
||||
type ExtendedToolState =
|
||||
| ToolUIPart['state']
|
||||
| 'approval-requested'
|
||||
| 'approval-responded'
|
||||
| 'output-denied'
|
||||
type ExtendedToolApproval = { approved: boolean }
|
||||
|
||||
type ConfirmationContextValue = {
|
||||
approval: ExtendedToolApproval | undefined
|
||||
state: ExtendedToolState
|
||||
}
|
||||
|
||||
const ConfirmationContext = createContext<ConfirmationContextValue | null>(null)
|
||||
|
||||
const useConfirmation = () => {
|
||||
const context = useContext(ConfirmationContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Confirmation components must be used within Confirmation')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export type ConfirmationProps = ComponentProps<typeof Alert> & {
|
||||
approval?: ExtendedToolApproval
|
||||
state: ExtendedToolState
|
||||
}
|
||||
|
||||
export const Confirmation = ({
|
||||
className,
|
||||
approval,
|
||||
state,
|
||||
...props
|
||||
}: ConfirmationProps) => {
|
||||
if (!approval || state === 'input-streaming' || state === 'input-available') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationContext.Provider value={{ approval, state }}>
|
||||
<Alert className={cn('flex flex-col gap-2', className)} {...props} />
|
||||
</ConfirmationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>
|
||||
|
||||
export const ConfirmationTitle = ({
|
||||
className,
|
||||
...props
|
||||
}: ConfirmationTitleProps) => (
|
||||
<AlertDescription className={cn('inline', className)} {...props} />
|
||||
)
|
||||
|
||||
export type ConfirmationRequestProps = {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
|
||||
const { state } = useConfirmation()
|
||||
|
||||
// Only show when approval is requested
|
||||
if (state !== 'approval-requested') {
|
||||
return null
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export type ConfirmationAcceptedProps = {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const ConfirmationAccepted = ({
|
||||
children,
|
||||
}: ConfirmationAcceptedProps) => {
|
||||
const { approval, state } = useConfirmation()
|
||||
|
||||
// Only show when approved and in response states
|
||||
if (
|
||||
!approval?.approved ||
|
||||
(state !== 'approval-responded' &&
|
||||
state !== 'output-denied' &&
|
||||
state !== 'output-available')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export type ConfirmationRejectedProps = {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const ConfirmationRejected = ({
|
||||
children,
|
||||
}: ConfirmationRejectedProps) => {
|
||||
const { approval, state } = useConfirmation()
|
||||
|
||||
// Only show when rejected and in response states
|
||||
if (
|
||||
approval?.approved !== false ||
|
||||
(state !== 'approval-responded' &&
|
||||
state !== 'output-denied' &&
|
||||
state !== 'output-available')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export type ConfirmationActionsProps = ComponentProps<'div'>
|
||||
|
||||
export const ConfirmationActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ConfirmationActionsProps) => {
|
||||
const { state } = useConfirmation()
|
||||
|
||||
// Only show when approval is requested
|
||||
if (state !== 'approval-requested') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-end gap-2 self-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type ConfirmationActionProps = ComponentProps<typeof Button>
|
||||
|
||||
export const ConfirmationAction = (props: ConfirmationActionProps) => (
|
||||
<Button className='h-8 px-3 text-sm' type='button' {...props} />
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ConnectionLineComponent } from '@xyflow/react'
|
||||
|
||||
const HALF = 0.5
|
||||
|
||||
export const Connection: ConnectionLineComponent = ({
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
}) => (
|
||||
<g>
|
||||
<path
|
||||
className='animated'
|
||||
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
|
||||
fill='none'
|
||||
stroke='var(--color-ring)'
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<circle
|
||||
cx={toX}
|
||||
cy={toY}
|
||||
fill='#fff'
|
||||
r={3}
|
||||
stroke='var(--color-ring)'
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
'use client'
|
||||
|
||||
import { type ComponentProps, createContext, useContext } from 'react'
|
||||
import type { LanguageModelUsage } from 'ai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getUsage } from 'tokenlens'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
|
||||
const PERCENT_MAX = 100
|
||||
const ICON_RADIUS = 10
|
||||
const ICON_VIEWBOX = 24
|
||||
const ICON_CENTER = 12
|
||||
const ICON_STROKE_WIDTH = 2
|
||||
|
||||
type ModelId = string
|
||||
|
||||
type ContextSchema = {
|
||||
usedTokens: number
|
||||
maxTokens: number
|
||||
usage?: LanguageModelUsage
|
||||
modelId?: ModelId
|
||||
}
|
||||
|
||||
const ContextContext = createContext<ContextSchema | null>(null)
|
||||
|
||||
const useContextValue = () => {
|
||||
const context = useContext(ContextContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Context components must be used within Context')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema
|
||||
|
||||
export const Context = ({
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
...props
|
||||
}: ContextProps) => (
|
||||
<ContextContext.Provider
|
||||
value={{
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
}}
|
||||
>
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
</ContextContext.Provider>
|
||||
)
|
||||
|
||||
const ContextIcon = () => {
|
||||
const { t } = useTranslation()
|
||||
const { usedTokens, maxTokens } = useContextValue()
|
||||
const circumference = 2 * Math.PI * ICON_RADIUS
|
||||
const usedPercent = usedTokens / maxTokens
|
||||
const dashOffset = circumference * (1 - usedPercent)
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-label={t('Model context usage')}
|
||||
height='20'
|
||||
role='img'
|
||||
style={{ color: 'currentcolor' }}
|
||||
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
|
||||
width='20'
|
||||
>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill='none'
|
||||
opacity='0.25'
|
||||
r={ICON_RADIUS}
|
||||
stroke='currentColor'
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill='none'
|
||||
opacity='0.7'
|
||||
r={ICON_RADIUS}
|
||||
stroke='currentColor'
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap='round'
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
style={{ transformOrigin: 'center', transform: 'rotate(-90deg)' }}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextTriggerProps = ComponentProps<typeof Button>
|
||||
|
||||
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue()
|
||||
const usedPercent = usedTokens / maxTokens
|
||||
const renderedPercent = new Intl.NumberFormat('en-US', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(usedPercent)
|
||||
|
||||
return (
|
||||
<HoverCardTrigger asChild>
|
||||
{children ?? (
|
||||
<Button type='button' variant='ghost' {...props}>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
{renderedPercent}
|
||||
</span>
|
||||
<ContextIcon />
|
||||
</Button>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextContentProps = ComponentProps<typeof HoverCardContent>
|
||||
|
||||
export const ContextContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ContextContentProps) => (
|
||||
<HoverCardContent
|
||||
className={cn('min-w-60 divide-y overflow-hidden p-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ContextContentHeaderProps = ComponentProps<'div'>
|
||||
|
||||
export const ContextContentHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentHeaderProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue()
|
||||
const usedPercent = usedTokens / maxTokens
|
||||
const displayPct = new Intl.NumberFormat('en-US', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(usedPercent)
|
||||
const used = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
}).format(usedTokens)
|
||||
const total = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
}).format(maxTokens)
|
||||
|
||||
return (
|
||||
<div className={cn('w-full space-y-2 p-3', className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className='flex items-center justify-between gap-3 text-xs'>
|
||||
<p>{displayPct}</p>
|
||||
<p className='text-muted-foreground font-mono'>
|
||||
{used} / {total}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Progress className='bg-muted' value={usedPercent * PERCENT_MAX} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextContentBodyProps = ComponentProps<'div'>
|
||||
|
||||
export const ContextContentBody = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentBodyProps) => (
|
||||
<div className={cn('w-full p-3', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type ContextContentFooterProps = ComponentProps<'div'>
|
||||
|
||||
export const ContextContentFooter = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentFooterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { modelId, usage } = useContextValue()
|
||||
const costUSD = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: {
|
||||
input: usage?.inputTokens ?? 0,
|
||||
output: usage?.outputTokens ?? 0,
|
||||
},
|
||||
}).costUSD?.totalUSD
|
||||
: undefined
|
||||
const totalCost = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(costUSD ?? 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-secondary flex w-full items-center justify-between gap-3 p-3 text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className='text-muted-foreground'>{t('Total cost')}</span>
|
||||
<span>{totalCost}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextInputUsageProps = ComponentProps<'div'>
|
||||
|
||||
export const ContextInputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextInputUsageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { usage, modelId } = useContextValue()
|
||||
const inputTokens = usage?.inputTokens ?? 0
|
||||
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!inputTokens) {
|
||||
return null
|
||||
}
|
||||
|
||||
const inputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: inputTokens, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined
|
||||
const inputCostText = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(inputCost ?? 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-between text-xs', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className='text-muted-foreground'>{t('Input')}</span>
|
||||
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextOutputUsageProps = ComponentProps<'div'>
|
||||
|
||||
export const ContextOutputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextOutputUsageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { usage, modelId } = useContextValue()
|
||||
const outputTokens = usage?.outputTokens ?? 0
|
||||
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!outputTokens) {
|
||||
return null
|
||||
}
|
||||
|
||||
const outputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: 0, output: outputTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined
|
||||
const outputCostText = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(outputCost ?? 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-between text-xs', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className='text-muted-foreground'>{t('Output')}</span>
|
||||
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextReasoningUsageProps = ComponentProps<'div'>
|
||||
|
||||
export const ContextReasoningUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextReasoningUsageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { usage, modelId } = useContextValue()
|
||||
const reasoningTokens = usage?.reasoningTokens ?? 0
|
||||
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!reasoningTokens) {
|
||||
return null
|
||||
}
|
||||
|
||||
const reasoningCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { reasoningTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined
|
||||
const reasoningCostText = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(reasoningCost ?? 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-between text-xs', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className='text-muted-foreground'>{t('Reasoning')}</span>
|
||||
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextCacheUsageProps = ComponentProps<'div'>
|
||||
|
||||
export const ContextCacheUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextCacheUsageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { usage, modelId } = useContextValue()
|
||||
const cacheTokens = usage?.cachedInputTokens ?? 0
|
||||
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!cacheTokens) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined
|
||||
const cacheCostText = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(cacheCost ?? 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-between text-xs', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className='text-muted-foreground'>{t('Cache')}</span>
|
||||
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TokensWithCost = ({
|
||||
tokens,
|
||||
costText,
|
||||
}: {
|
||||
tokens?: number
|
||||
costText?: string
|
||||
}) => (
|
||||
<span>
|
||||
{tokens === undefined
|
||||
? '—'
|
||||
: new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
}).format(tokens)}
|
||||
{costText ? (
|
||||
<span className='text-muted-foreground ml-2'>• {costText}</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Controls as ControlsPrimitive } from '@xyflow/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>
|
||||
|
||||
export const Controls = ({ className, ...props }: ControlsProps) => (
|
||||
<ControlsPrimitive
|
||||
className={cn(
|
||||
'bg-card gap-px overflow-hidden rounded-md border p-1 shadow-none!',
|
||||
'[&>button]:hover:bg-secondary! [&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent!',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { type ComponentProps, useCallback } from 'react'
|
||||
import { ArrowDownIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-auto', className)}
|
||||
initial='smooth'
|
||||
resize='smooth'
|
||||
role='log'
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content className={cn('p-4', className)} {...props} />
|
||||
)
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<'div'> & {
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => {
|
||||
const { t } = useTranslation()
|
||||
const resolvedTitle = title ?? t('No messages yet')
|
||||
const resolvedDescription =
|
||||
description ?? t('Start a conversation to see messages here')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className='text-muted-foreground'>{icon}</div>}
|
||||
<div className='space-y-1'>
|
||||
<h3 className='text-sm font-medium'>{resolvedTitle}</h3>
|
||||
{resolvedDescription && (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{resolvedDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom()
|
||||
}, [scrollToBottom])
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
'absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size='icon'
|
||||
type='button'
|
||||
variant='outline'
|
||||
aria-label='Scroll to bottom'
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className='size-4' aria-hidden='true' />
|
||||
</Button>
|
||||
)
|
||||
)
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
BaseEdge,
|
||||
type EdgeProps,
|
||||
getBezierPath,
|
||||
getSimpleBezierPath,
|
||||
type InternalNode,
|
||||
type Node,
|
||||
Position,
|
||||
useInternalNode,
|
||||
} from '@xyflow/react'
|
||||
|
||||
const Temporary = ({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
}: EdgeProps) => {
|
||||
const [edgePath] = getSimpleBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
className='stroke-ring stroke-1'
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
strokeDasharray: '5, 5',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const getHandleCoordsByPosition = (
|
||||
node: InternalNode<Node>,
|
||||
handlePosition: Position
|
||||
) => {
|
||||
// Choose the handle type based on position - Left is for target, Right is for source
|
||||
const handleType = handlePosition === Position.Left ? 'target' : 'source'
|
||||
|
||||
const handle = node.internals.handleBounds?.[handleType]?.find(
|
||||
(h) => h.position === handlePosition
|
||||
)
|
||||
|
||||
if (!handle) {
|
||||
return [0, 0] as const
|
||||
}
|
||||
|
||||
let offsetX = handle.width / 2
|
||||
let offsetY = handle.height / 2
|
||||
|
||||
// this is a tiny detail to make the markerEnd of an edge visible.
|
||||
// The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
|
||||
// when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
|
||||
switch (handlePosition) {
|
||||
case Position.Left:
|
||||
offsetX = 0
|
||||
break
|
||||
case Position.Right:
|
||||
offsetX = handle.width
|
||||
break
|
||||
case Position.Top:
|
||||
offsetY = 0
|
||||
break
|
||||
case Position.Bottom:
|
||||
offsetY = handle.height
|
||||
break
|
||||
default:
|
||||
throw new Error(`Invalid handle position: ${handlePosition}`)
|
||||
}
|
||||
|
||||
const x = node.internals.positionAbsolute.x + handle.x + offsetX
|
||||
const y = node.internals.positionAbsolute.y + handle.y + offsetY
|
||||
|
||||
return [x, y] as const
|
||||
}
|
||||
|
||||
const getEdgeParams = (
|
||||
source: InternalNode<Node>,
|
||||
target: InternalNode<Node>
|
||||
) => {
|
||||
const sourcePos = Position.Right
|
||||
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos)
|
||||
const targetPos = Position.Left
|
||||
const [tx, ty] = getHandleCoordsByPosition(target, targetPos)
|
||||
|
||||
return {
|
||||
sx,
|
||||
sy,
|
||||
tx,
|
||||
ty,
|
||||
sourcePos,
|
||||
targetPos,
|
||||
}
|
||||
}
|
||||
|
||||
const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
|
||||
const sourceNode = useInternalNode(source)
|
||||
const targetNode = useInternalNode(target)
|
||||
|
||||
if (!(sourceNode && targetNode)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
|
||||
sourceNode,
|
||||
targetNode
|
||||
)
|
||||
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
sourcePosition: sourcePos,
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
targetPosition: targetPos,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
|
||||
<circle fill='var(--primary)' r='4'>
|
||||
<animateMotion dur='2s' path={edgePath} repeatCount='indefinite' />
|
||||
</circle>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Edge = {
|
||||
Temporary,
|
||||
Animated,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Experimental_GeneratedImage } from 'ai'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ImageProps = Experimental_GeneratedImage & {
|
||||
className?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export const Image = ({
|
||||
base64,
|
||||
uint8Array,
|
||||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
className={cn(
|
||||
'h-auto max-w-full overflow-hidden rounded-md',
|
||||
props.className
|
||||
)}
|
||||
src={`data:${mediaType};base64,${base64}`}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,297 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from '@/components/ui/carousel'
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
|
||||
export type InlineCitationProps = ComponentProps<'span'>
|
||||
|
||||
export const InlineCitation = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationProps) => (
|
||||
<span
|
||||
className={cn('group inline items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type InlineCitationTextProps = ComponentProps<'span'>
|
||||
|
||||
export const InlineCitationText = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationTextProps) => (
|
||||
<span
|
||||
className={cn('group-hover:bg-accent transition-colors', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>
|
||||
|
||||
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
)
|
||||
|
||||
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
|
||||
sources: string[]
|
||||
}
|
||||
|
||||
export const InlineCitationCardTrigger = ({
|
||||
sources,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardTriggerProps) => (
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge
|
||||
className={cn('ml-1 rounded-full', className)}
|
||||
variant='secondary'
|
||||
{...props}
|
||||
>
|
||||
{sources[0] ? (
|
||||
<>
|
||||
{new URL(sources[0]).hostname}{' '}
|
||||
{sources.length > 1 && `+${sources.length - 1}`}
|
||||
</>
|
||||
) : (
|
||||
'unknown'
|
||||
)}
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
)
|
||||
|
||||
export type InlineCitationCardBodyProps = ComponentProps<'div'>
|
||||
|
||||
export const InlineCitationCardBody = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardBodyProps) => (
|
||||
<HoverCardContent className={cn('relative w-80 p-0', className)} {...props} />
|
||||
)
|
||||
|
||||
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined)
|
||||
|
||||
const useCarouselApi = () => {
|
||||
const context = useContext(CarouselApiContext)
|
||||
return context
|
||||
}
|
||||
|
||||
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>
|
||||
|
||||
export const InlineCitationCarousel = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationCarouselProps) => {
|
||||
const [api, setApi] = useState<CarouselApi>()
|
||||
|
||||
return (
|
||||
<CarouselApiContext.Provider value={api}>
|
||||
<Carousel className={cn('w-full', className)} setApi={setApi} {...props}>
|
||||
{children}
|
||||
</Carousel>
|
||||
</CarouselApiContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type InlineCitationCarouselContentProps = ComponentProps<'div'>
|
||||
|
||||
export const InlineCitationCarouselContent = (
|
||||
props: InlineCitationCarouselContentProps
|
||||
) => <CarouselContent {...props} />
|
||||
|
||||
export type InlineCitationCarouselItemProps = ComponentProps<'div'>
|
||||
|
||||
export const InlineCitationCarouselItem = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselItemProps) => (
|
||||
<CarouselItem
|
||||
className={cn('w-full space-y-2 p-4 pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>
|
||||
|
||||
export const InlineCitationCarouselHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-secondary flex items-center justify-between gap-2 rounded-t-md p-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type InlineCitationCarouselIndexProps = ComponentProps<'div'>
|
||||
|
||||
export const InlineCitationCarouselIndex = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselIndexProps) => {
|
||||
const api = useCarouselApi()
|
||||
const [current, setCurrent] = useState(0)
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setCount(api.scrollSnapList().length)
|
||||
|
||||
setCurrent(api.selectedScrollSnap() + 1)
|
||||
|
||||
api.on('select', () => {
|
||||
setCurrent(api.selectedScrollSnap() + 1)
|
||||
})
|
||||
}, [api])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground flex flex-1 items-center justify-end px-3 py-1 text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? `${current}/${count}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type InlineCitationCarouselPrevProps = ComponentProps<'button'>
|
||||
|
||||
export const InlineCitationCarouselPrev = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselPrevProps) => {
|
||||
const { t } = useTranslation()
|
||||
const api = useCarouselApi()
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollPrev()
|
||||
}
|
||||
}, [api])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
aria-label={t('Previous')}
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={handleClick}
|
||||
type='button'
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeftIcon className='text-muted-foreground size-4' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type InlineCitationCarouselNextProps = ComponentProps<'button'>
|
||||
|
||||
export const InlineCitationCarouselNext = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselNextProps) => {
|
||||
const { t } = useTranslation()
|
||||
const api = useCarouselApi()
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollNext()
|
||||
}
|
||||
}, [api])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
aria-label={t('Next')}
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={handleClick}
|
||||
type='button'
|
||||
{...props}
|
||||
>
|
||||
<ArrowRightIcon className='text-muted-foreground size-4' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type InlineCitationSourceProps = ComponentProps<'div'> & {
|
||||
title?: string
|
||||
url?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const InlineCitationSource = ({
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationSourceProps) => (
|
||||
<div className={cn('space-y-1', className)} {...props}>
|
||||
{title && (
|
||||
<h4 className='truncate text-sm leading-tight font-medium'>{title}</h4>
|
||||
)}
|
||||
{url && (
|
||||
<p className='text-muted-foreground truncate text-xs break-all'>{url}</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className='text-muted-foreground line-clamp-3 text-sm leading-relaxed'>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type InlineCitationQuoteProps = ComponentProps<'blockquote'>
|
||||
|
||||
export const InlineCitationQuote = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationQuoteProps) => (
|
||||
<blockquote
|
||||
className={cn(
|
||||
'border-muted text-muted-foreground border-l-2 pl-3 text-sm italic',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type LoaderIconProps = {
|
||||
size?: number
|
||||
}
|
||||
|
||||
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin='round'
|
||||
style={{ color: 'currentcolor' }}
|
||||
viewBox='0 0 16 16'
|
||||
width={size}
|
||||
>
|
||||
<title>{t('Loader')}</title>
|
||||
<g clipPath='url(#clip0_2393_1490)'>
|
||||
<path d='M8 0V4' stroke='currentColor' strokeWidth='1.5' />
|
||||
<path
|
||||
d='M8 16V12'
|
||||
opacity='0.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M3.29773 1.52783L5.64887 4.7639'
|
||||
opacity='0.9'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M12.7023 1.52783L10.3511 4.7639'
|
||||
opacity='0.1'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M12.7023 14.472L10.3511 11.236'
|
||||
opacity='0.4'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M3.29773 14.472L5.64887 11.236'
|
||||
opacity='0.6'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M15.6085 5.52783L11.8043 6.7639'
|
||||
opacity='0.2'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M0.391602 10.472L4.19583 9.23598'
|
||||
opacity='0.7'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M15.6085 10.4722L11.8043 9.2361'
|
||||
opacity='0.3'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
<path
|
||||
d='M0.391602 5.52783L4.19583 6.7639'
|
||||
opacity='0.8'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='clip0_2393_1490'>
|
||||
<rect fill='white' height='16' width='16' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
size?: number
|
||||
}
|
||||
|
||||
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex animate-spin items-center justify-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<LoaderIcon size={size} />
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ComponentProps, HTMLAttributes } from 'react'
|
||||
import type { UIMessage } from 'ai'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage['role']
|
||||
}
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full items-end justify-end gap-2',
|
||||
from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const messageContentVariants = cva(
|
||||
'is-user:dark flex flex-col gap-2 overflow-hidden rounded-lg text-sm',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
contained: [
|
||||
'max-w-[100%] px-3 py-1',
|
||||
'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
|
||||
'group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground',
|
||||
],
|
||||
flat: [
|
||||
'group-[.is-user]:max-w-[80%] group-[.is-user]:bg-secondary group-[.is-user]:px-3 group-[.is-user]:py-1 group-[.is-user]:text-foreground',
|
||||
'group-[.is-assistant]:text-foreground',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'contained',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement> &
|
||||
VariantProps<typeof messageContentVariants>
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(messageContentVariants({ variant, className }))}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type MessageAvatarProps = ComponentProps<typeof Avatar> & {
|
||||
src: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export const MessageAvatar = ({
|
||||
src,
|
||||
name,
|
||||
className,
|
||||
...props
|
||||
}: MessageAvatarProps) => (
|
||||
<Avatar className={cn('ring-border size-8 ring-1', className)} {...props}>
|
||||
<AvatarImage alt='' className='mt-0 mb-0' src={src} />
|
||||
<AvatarFallback>{name?.slice(0, 2) || 'ME'}</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
|
||||
export type NodeProps = ComponentProps<typeof Card> & {
|
||||
handles: {
|
||||
target: boolean
|
||||
source: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const Node = ({ handles, className, ...props }: NodeProps) => (
|
||||
<Card
|
||||
className={cn(
|
||||
'node-container relative size-full h-auto w-sm gap-0 rounded-md p-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{handles.target && <Handle position={Position.Left} type='target' />}
|
||||
{handles.source && <Handle position={Position.Right} type='source' />}
|
||||
{props.children}
|
||||
</Card>
|
||||
)
|
||||
|
||||
export type NodeHeaderProps = ComponentProps<typeof CardHeader>
|
||||
|
||||
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn('bg-secondary gap-0.5 rounded-t-md border-b p-3!', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type NodeTitleProps = ComponentProps<typeof CardTitle>
|
||||
|
||||
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />
|
||||
|
||||
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>
|
||||
|
||||
export const NodeDescription = (props: NodeDescriptionProps) => (
|
||||
<CardDescription {...props} />
|
||||
)
|
||||
|
||||
export type NodeActionProps = ComponentProps<typeof CardAction>
|
||||
|
||||
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />
|
||||
|
||||
export type NodeContentProps = ComponentProps<typeof CardContent>
|
||||
|
||||
export const NodeContent = ({ className, ...props }: NodeContentProps) => (
|
||||
<CardContent className={cn('p-3', className)} {...props} />
|
||||
)
|
||||
|
||||
export type NodeFooterProps = ComponentProps<typeof CardFooter>
|
||||
|
||||
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
|
||||
<CardFooter
|
||||
className={cn('bg-secondary rounded-b-md border-t p-3!', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,367 @@
|
||||
import { type ComponentProps, createContext, useContext } from 'react'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
MessageCircleIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
const providers = {
|
||||
github: {
|
||||
title: 'Open in GitHub',
|
||||
createUrl: (url: string) => url,
|
||||
icon: (
|
||||
<svg fill='currentColor' role='img' viewBox='0 0 24 24'>
|
||||
<title>GitHub</title>
|
||||
<path d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12' />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
scira: {
|
||||
title: 'Open in Scira',
|
||||
createUrl: (q: string) =>
|
||||
`https://scira.ai/?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill='none'
|
||||
height='934'
|
||||
viewBox='0 0 910 934'
|
||||
width='910'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<title>Scira AI</title>
|
||||
<path
|
||||
d='M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z'
|
||||
fill='currentColor'
|
||||
stroke='currentColor'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='8'
|
||||
/>
|
||||
<path
|
||||
d='M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z'
|
||||
fill='currentColor'
|
||||
stroke='currentColor'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='8'
|
||||
/>
|
||||
<path
|
||||
d='M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z'
|
||||
stroke='currentColor'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='20'
|
||||
/>
|
||||
<path
|
||||
d='M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z'
|
||||
stroke='currentColor'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='20'
|
||||
/>
|
||||
<path
|
||||
d='M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z'
|
||||
fill='currentColor'
|
||||
stroke='currentColor'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='8'
|
||||
/>
|
||||
<path
|
||||
d='M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z'
|
||||
fill='currentColor'
|
||||
stroke='currentColor'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='8'
|
||||
/>
|
||||
<path
|
||||
d='M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442'
|
||||
stroke='currentColor'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='30'
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
chatgpt: {
|
||||
title: 'Open in ChatGPT',
|
||||
createUrl: (prompt: string) =>
|
||||
`https://chatgpt.com/?${new URLSearchParams({
|
||||
hints: 'search',
|
||||
prompt,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill='currentColor'
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<title>OpenAI</title>
|
||||
<path d='M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z' />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
claude: {
|
||||
title: 'Open in Claude',
|
||||
createUrl: (q: string) =>
|
||||
`https://claude.ai/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill='currentColor'
|
||||
role='img'
|
||||
viewBox='0 0 12 12'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<title>Claude</title>
|
||||
<path
|
||||
clipRule='evenodd'
|
||||
d='M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z'
|
||||
fillRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
t3: {
|
||||
title: 'Open in T3 Chat',
|
||||
createUrl: (q: string) =>
|
||||
`https://t3.chat/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: <MessageCircleIcon />,
|
||||
},
|
||||
v0: {
|
||||
title: 'Open in v0',
|
||||
createUrl: (q: string) =>
|
||||
`https://v0.app?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill='currentColor'
|
||||
viewBox='0 0 147 70'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<title>v0</title>
|
||||
<path d='M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z' />
|
||||
<path d='M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z' />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
cursor: {
|
||||
title: 'Open in Cursor',
|
||||
createUrl: (text: string) => {
|
||||
const url = new URL('https://cursor.com/link/prompt')
|
||||
url.searchParams.set('text', text)
|
||||
return url.toString()
|
||||
},
|
||||
icon: (
|
||||
<svg
|
||||
version='1.1'
|
||||
viewBox='0 0 466.73 532.09'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<title>Cursor</title>
|
||||
<path
|
||||
d='M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
const OpenInContext = createContext<{ query: string } | undefined>(undefined)
|
||||
|
||||
const useOpenInContext = () => {
|
||||
const context = useContext(OpenInContext)
|
||||
if (!context) {
|
||||
throw new Error('OpenIn components must be used within an OpenIn provider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type OpenInProps = ComponentProps<typeof DropdownMenu> & {
|
||||
query: string
|
||||
}
|
||||
|
||||
export const OpenIn = ({ query, ...props }: OpenInProps) => (
|
||||
<OpenInContext.Provider value={{ query }}>
|
||||
<DropdownMenu {...props} />
|
||||
</OpenInContext.Provider>
|
||||
)
|
||||
|
||||
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>
|
||||
|
||||
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
className={cn('w-[240px]', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>
|
||||
|
||||
export const OpenInItem = (props: OpenInItemProps) => (
|
||||
<DropdownMenuItem {...props} />
|
||||
)
|
||||
|
||||
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>
|
||||
|
||||
export const OpenInLabel = (props: OpenInLabelProps) => (
|
||||
<DropdownMenuLabel {...props} />
|
||||
)
|
||||
|
||||
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>
|
||||
|
||||
export const OpenInSeparator = (props: OpenInSeparatorProps) => (
|
||||
<DropdownMenuSeparator {...props} />
|
||||
)
|
||||
|
||||
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>
|
||||
|
||||
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<DropdownMenuTrigger {...props} asChild>
|
||||
{children ?? (
|
||||
<Button type='button' variant='outline'>
|
||||
{t('Open in chat')}
|
||||
<ChevronDownIcon className='ml-2 size-4' />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>
|
||||
|
||||
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
|
||||
const { query } = useOpenInContext()
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className='flex items-center gap-2'
|
||||
href={providers.chatgpt.createUrl(query)}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
>
|
||||
<span className='shrink-0'>{providers.chatgpt.icon}</span>
|
||||
<span className='flex-1'>{providers.chatgpt.title}</span>
|
||||
<ExternalLinkIcon className='size-4 shrink-0' />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>
|
||||
|
||||
export const OpenInClaude = (props: OpenInClaudeProps) => {
|
||||
const { query } = useOpenInContext()
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className='flex items-center gap-2'
|
||||
href={providers.claude.createUrl(query)}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
>
|
||||
<span className='shrink-0'>{providers.claude.icon}</span>
|
||||
<span className='flex-1'>{providers.claude.title}</span>
|
||||
<ExternalLinkIcon className='size-4 shrink-0' />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>
|
||||
|
||||
export const OpenInT3 = (props: OpenInT3Props) => {
|
||||
const { query } = useOpenInContext()
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className='flex items-center gap-2'
|
||||
href={providers.t3.createUrl(query)}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
>
|
||||
<span className='shrink-0'>{providers.t3.icon}</span>
|
||||
<span className='flex-1'>{providers.t3.title}</span>
|
||||
<ExternalLinkIcon className='size-4 shrink-0' />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>
|
||||
|
||||
export const OpenInScira = (props: OpenInSciraProps) => {
|
||||
const { query } = useOpenInContext()
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className='flex items-center gap-2'
|
||||
href={providers.scira.createUrl(query)}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
>
|
||||
<span className='shrink-0'>{providers.scira.icon}</span>
|
||||
<span className='flex-1'>{providers.scira.title}</span>
|
||||
<ExternalLinkIcon className='size-4 shrink-0' />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>
|
||||
|
||||
export const OpenInv0 = (props: OpenInv0Props) => {
|
||||
const { query } = useOpenInContext()
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className='flex items-center gap-2'
|
||||
href={providers.v0.createUrl(query)}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
>
|
||||
<span className='shrink-0'>{providers.v0.icon}</span>
|
||||
<span className='flex-1'>{providers.v0.title}</span>
|
||||
<ExternalLinkIcon className='size-4 shrink-0' />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>
|
||||
|
||||
export const OpenInCursor = (props: OpenInCursorProps) => {
|
||||
const { query } = useOpenInContext()
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className='flex items-center gap-2'
|
||||
href={providers.cursor.createUrl(query)}
|
||||
rel='noopener'
|
||||
target='_blank'
|
||||
>
|
||||
<span className='shrink-0'>{providers.cursor.icon}</span>
|
||||
<span className='flex-1'>{providers.cursor.title}</span>
|
||||
<ExternalLinkIcon className='size-4 shrink-0' />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Panel as PanelPrimitive } from '@xyflow/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type PanelProps = ComponentProps<typeof PanelPrimitive>
|
||||
|
||||
export const Panel = ({ className, ...props }: PanelProps) => (
|
||||
<PanelPrimitive
|
||||
className={cn(
|
||||
'bg-card m-4 overflow-hidden rounded-md border p-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import { type ComponentProps, createContext, useContext } from 'react'
|
||||
import { ChevronsUpDownIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Shimmer } from './shimmer'
|
||||
|
||||
type PlanContextValue = {
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
const PlanContext = createContext<PlanContextValue | null>(null)
|
||||
|
||||
const usePlan = () => {
|
||||
const context = useContext(PlanContext)
|
||||
if (!context) {
|
||||
throw new Error('Plan components must be used within Plan')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type PlanProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
export const Plan = ({
|
||||
className,
|
||||
isStreaming = false,
|
||||
children,
|
||||
...props
|
||||
}: PlanProps) => (
|
||||
<PlanContext.Provider value={{ isStreaming }}>
|
||||
<Collapsible asChild data-slot='plan' {...props}>
|
||||
<Card className={cn('shadow-none', className)}>{children}</Card>
|
||||
</Collapsible>
|
||||
</PlanContext.Provider>
|
||||
)
|
||||
|
||||
export type PlanHeaderProps = ComponentProps<typeof CardHeader>
|
||||
|
||||
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn('flex items-start justify-between', className)}
|
||||
data-slot='plan-header'
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type PlanTitleProps = Omit<
|
||||
ComponentProps<typeof CardTitle>,
|
||||
'children'
|
||||
> & {
|
||||
children: string
|
||||
}
|
||||
|
||||
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
|
||||
const { isStreaming } = usePlan()
|
||||
|
||||
return (
|
||||
<CardTitle data-slot='plan-title' {...props}>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardTitle>
|
||||
)
|
||||
}
|
||||
|
||||
export type PlanDescriptionProps = Omit<
|
||||
ComponentProps<typeof CardDescription>,
|
||||
'children'
|
||||
> & {
|
||||
children: string
|
||||
}
|
||||
|
||||
export const PlanDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PlanDescriptionProps) => {
|
||||
const { isStreaming } = usePlan()
|
||||
|
||||
return (
|
||||
<CardDescription
|
||||
className={cn('text-balance', className)}
|
||||
data-slot='plan-description'
|
||||
{...props}
|
||||
>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardDescription>
|
||||
)
|
||||
}
|
||||
|
||||
export type PlanActionProps = ComponentProps<typeof CardAction>
|
||||
|
||||
export const PlanAction = (props: PlanActionProps) => (
|
||||
<CardAction data-slot='plan-action' {...props} />
|
||||
)
|
||||
|
||||
export type PlanContentProps = ComponentProps<typeof CardContent>
|
||||
|
||||
export const PlanContent = (props: PlanContentProps) => (
|
||||
<CollapsibleContent asChild>
|
||||
<CardContent data-slot='plan-content' {...props} />
|
||||
</CollapsibleContent>
|
||||
)
|
||||
|
||||
export type PlanFooterProps = ComponentProps<'div'>
|
||||
|
||||
export const PlanFooter = (props: PlanFooterProps) => (
|
||||
<CardFooter data-slot='plan-footer' {...props} />
|
||||
)
|
||||
|
||||
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>
|
||||
|
||||
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className={cn('size-8', className)}
|
||||
data-slot='plan-trigger'
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className='size-4' />
|
||||
<span className='sr-only'>{t('Toggle plan')}</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
}
|
||||
+1386
File diff suppressed because it is too large
Load Diff
+273
@@ -0,0 +1,273 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import { ChevronDownIcon, PaperclipIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
export type QueueMessagePart = {
|
||||
type: string
|
||||
text?: string
|
||||
url?: string
|
||||
filename?: string
|
||||
mediaType?: string
|
||||
}
|
||||
|
||||
export type QueueMessage = {
|
||||
id: string
|
||||
parts: QueueMessagePart[]
|
||||
}
|
||||
|
||||
export type QueueTodo = {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
status?: 'pending' | 'completed'
|
||||
}
|
||||
|
||||
export type QueueItemProps = ComponentProps<'li'>
|
||||
|
||||
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
|
||||
<li
|
||||
className={cn(
|
||||
'group hover:bg-muted flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type QueueItemIndicatorProps = ComponentProps<'span'> & {
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
export const QueueItemIndicator = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemIndicatorProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 inline-block size-2.5 rounded-full border',
|
||||
completed
|
||||
? 'border-muted-foreground/20 bg-muted-foreground/10'
|
||||
: 'border-muted-foreground/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type QueueItemContentProps = ComponentProps<'span'> & {
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
export const QueueItemContent = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemContentProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
'line-clamp-1 grow break-words',
|
||||
completed
|
||||
? 'text-muted-foreground/50 line-through'
|
||||
: 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type QueueItemDescriptionProps = ComponentProps<'div'> & {
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
export const QueueItemDescription = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemDescriptionProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'ml-6 text-xs',
|
||||
completed
|
||||
? 'text-muted-foreground/40 line-through'
|
||||
: 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type QueueItemActionsProps = ComponentProps<'div'>
|
||||
|
||||
export const QueueItemActions = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionsProps) => (
|
||||
<div className={cn('flex gap-1', className)} {...props} />
|
||||
)
|
||||
|
||||
export type QueueItemActionProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
'variant' | 'size'
|
||||
>
|
||||
|
||||
export const QueueItemAction = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionProps) => (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground size-auto rounded p-1 opacity-0 transition-opacity group-hover:opacity-100',
|
||||
className
|
||||
)}
|
||||
size='icon'
|
||||
type='button'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type QueueItemAttachmentProps = ComponentProps<'div'>
|
||||
|
||||
export const QueueItemAttachment = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemAttachmentProps) => (
|
||||
<div className={cn('mt-1 flex flex-wrap gap-2', className)} {...props} />
|
||||
)
|
||||
|
||||
export type QueueItemImageProps = ComponentProps<'img'>
|
||||
|
||||
export const QueueItemImage = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemImageProps) => (
|
||||
<img
|
||||
alt=''
|
||||
className={cn('h-8 w-8 rounded border object-cover', className)}
|
||||
height={32}
|
||||
width={32}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type QueueItemFileProps = ComponentProps<'span'>
|
||||
|
||||
export const QueueItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemFileProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
'bg-muted flex items-center gap-1 rounded border px-2 py-1 text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PaperclipIcon size={12} />
|
||||
<span className='max-w-[100px] truncate'>{children}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
export type QueueListProps = ComponentProps<typeof ScrollArea>
|
||||
|
||||
export const QueueList = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueListProps) => (
|
||||
<ScrollArea className={cn('mt-2 -mb-1', className)} {...props}>
|
||||
<div className='max-h-40 pr-4'>
|
||||
<ul>{children}</ul>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
|
||||
// QueueSection - collapsible section container
|
||||
export type QueueSectionProps = ComponentProps<typeof Collapsible>
|
||||
|
||||
export const QueueSection = ({
|
||||
className,
|
||||
defaultOpen = true,
|
||||
...props
|
||||
}: QueueSectionProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
)
|
||||
|
||||
// QueueSectionTrigger - section header/trigger
|
||||
export type QueueSectionTriggerProps = ComponentProps<'button'>
|
||||
|
||||
export const QueueSectionTrigger = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionTriggerProps) => (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={cn(
|
||||
'group bg-muted/40 text-muted-foreground hover:bg-muted h-auto w-full justify-between px-3 py-2 text-left',
|
||||
className
|
||||
)}
|
||||
type='button'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
|
||||
// QueueSectionLabel - label content with icon and count
|
||||
export type QueueSectionLabelProps = ComponentProps<'span'> & {
|
||||
count?: number
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export const QueueSectionLabel = ({
|
||||
count,
|
||||
label,
|
||||
icon,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionLabelProps) => (
|
||||
<span className={cn('flex items-center gap-2', className)} {...props}>
|
||||
<ChevronDownIcon className='size-4 transition-transform group-data-[state=closed]:-rotate-90' />
|
||||
{icon}
|
||||
<span>
|
||||
{count} {label}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
// QueueSectionContent - collapsible content area
|
||||
export type QueueSectionContentProps = ComponentProps<typeof CollapsibleContent>
|
||||
|
||||
export const QueueSectionContent = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionContentProps) => (
|
||||
<CollapsibleContent className={cn(className)} {...props} />
|
||||
)
|
||||
|
||||
export type QueueProps = ComponentProps<'div'>
|
||||
|
||||
export const Queue = ({ className, ...props }: QueueProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border bg-background flex flex-col gap-2 rounded-xl border px-3 pt-2 pb-2 shadow-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
memo,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useControllableState } from '@radix-ui/react-use-controllable-state'
|
||||
import { BrainIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Response } from './response'
|
||||
import { Shimmer } from './shimmer'
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean
|
||||
isOpen: boolean
|
||||
setIsOpen: (open: boolean) => void
|
||||
duration: number
|
||||
}
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
|
||||
|
||||
const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext)
|
||||
if (!context) {
|
||||
throw new Error('Reasoning components must be used within Reasoning')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
duration?: number
|
||||
}
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000
|
||||
const MS_IN_S = 1000
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
})
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: 0,
|
||||
})
|
||||
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
||||
const [startTime, setStartTime] = useState<number | null>(null)
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setStartTime(Date.now())
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
|
||||
setStartTime(null)
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration])
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
setHasAutoClosed(true)
|
||||
}, AUTO_CLOSE_DELAY)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn('not-prose mb-4', className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>
|
||||
|
||||
const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>
|
||||
}
|
||||
// When duration is unknown or 0 (e.g., non-streaming responses), show a generic message
|
||||
if (duration === undefined || duration === 0) {
|
||||
return <p>Thought for a few seconds</p>
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>
|
||||
}
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({ className, children, ...props }: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning()
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className='size-4' />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'size-4 transition-transform',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string
|
||||
}
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-4 text-sm',
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Response className='grid gap-2'>{children}</Response>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
)
|
||||
|
||||
Reasoning.displayName = 'Reasoning'
|
||||
ReasoningTrigger.displayName = 'ReasoningTrigger'
|
||||
ReasoningContent.displayName = 'ReasoningContent'
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { type ComponentProps, memo } from 'react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown>
|
||||
|
||||
export const Response = memo(
|
||||
({ className, children, ...props }: ResponseProps) => {
|
||||
const stripCustomTags = (input: unknown): unknown => {
|
||||
if (typeof input !== 'string') return input
|
||||
return (
|
||||
input
|
||||
// Remove known AI custom wrapper tags but keep inner content
|
||||
.replace(
|
||||
/<\/?(conversation|conversationcontent|reasoning|reasoningcontent|reasoningtrigger|sources|sourcescontent|sourcestrigger|branch|branchmessages|branchnext|branchpage|branchprevious|branchselector|message|messagecontent)\b[^>]*>/gi,
|
||||
''
|
||||
)
|
||||
// Remove any stray <think> tags if they still appear
|
||||
.replace(/<\/?think\b[^>]*>/gi, '')
|
||||
)
|
||||
}
|
||||
|
||||
const safeChildren = stripCustomTags(children) as string
|
||||
|
||||
return (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{safeChildren}
|
||||
</Streamdown>
|
||||
)
|
||||
},
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
)
|
||||
|
||||
Response.displayName = 'Response'
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, type ElementType, memo, useMemo } from 'react'
|
||||
import { motion } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type TextShimmerProps = {
|
||||
children: string
|
||||
as?: ElementType
|
||||
className?: string
|
||||
duration?: number
|
||||
spread?: number
|
||||
}
|
||||
|
||||
const MotionP = motion.p
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
)
|
||||
|
||||
return (
|
||||
<MotionP
|
||||
animate={{ backgroundPosition: '0% center' }}
|
||||
className={cn(
|
||||
'relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent',
|
||||
'[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))]',
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: '100% center' }}
|
||||
style={
|
||||
{
|
||||
'--spread': `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
'var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))',
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: 'linear',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionP>
|
||||
)
|
||||
}
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent)
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import { BookIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
|
||||
export type SourcesProps = ComponentProps<'div'>
|
||||
|
||||
export const Sources = ({ className, ...props }: SourcesProps) => (
|
||||
<Collapsible
|
||||
className={cn('not-prose text-primary mb-4 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
count: number
|
||||
}
|
||||
|
||||
export const SourcesTrigger = ({
|
||||
className,
|
||||
count,
|
||||
children,
|
||||
...props
|
||||
}: SourcesTriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn('flex items-center gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<p className='font-medium'>
|
||||
{t('Used')} {count} {t('sources')}
|
||||
</p>
|
||||
<ChevronDownIcon className='h-4 w-4' />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>
|
||||
|
||||
export const SourcesContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-3 flex w-fit flex-col gap-2',
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type SourceProps = ComponentProps<'a'>
|
||||
|
||||
export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
||||
<a
|
||||
className='flex items-center gap-2'
|
||||
href={href}
|
||||
rel='noreferrer'
|
||||
target='_blank'
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BookIcon className='h-4 w-4' />
|
||||
<span className='block font-medium'>{title}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
|
||||
export type SuggestionsProps = ComponentProps<typeof ScrollArea>
|
||||
|
||||
export const Suggestions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SuggestionsProps) => (
|
||||
<ScrollArea className='w-full overflow-x-auto whitespace-nowrap' {...props}>
|
||||
<div className={cn('flex w-max flex-nowrap items-center gap-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
<ScrollBar className='hidden' orientation='horizontal' />
|
||||
</ScrollArea>
|
||||
)
|
||||
|
||||
export type SuggestionProps = Omit<ComponentProps<typeof Button>, 'onClick'> & {
|
||||
suggestion: string
|
||||
onClick?: (suggestion: string) => void
|
||||
}
|
||||
|
||||
export const Suggestion = ({
|
||||
suggestion,
|
||||
onClick,
|
||||
className,
|
||||
variant = 'outline',
|
||||
size = 'sm',
|
||||
children,
|
||||
...props
|
||||
}: SuggestionProps) => {
|
||||
const handleClick = () => {
|
||||
onClick?.(suggestion)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('cursor-pointer rounded-full px-4', className)}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
type='button'
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children || suggestion}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import { ChevronDownIcon, SearchIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
|
||||
export type TaskItemFileProps = ComponentProps<'div'>
|
||||
|
||||
export const TaskItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskItemFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-secondary text-foreground inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type TaskItemProps = ComponentProps<'div'>
|
||||
|
||||
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
|
||||
<div className={cn('text-muted-foreground text-sm', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type TaskProps = ComponentProps<typeof Collapsible>
|
||||
|
||||
export const Task = ({
|
||||
defaultOpen = true,
|
||||
className,
|
||||
...props
|
||||
}: TaskProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
)
|
||||
|
||||
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
title: string
|
||||
}
|
||||
|
||||
export const TaskTrigger = ({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: TaskTriggerProps) => (
|
||||
<CollapsibleTrigger asChild className={cn('group', className)} {...props}>
|
||||
{children ?? (
|
||||
<div className='text-muted-foreground hover:text-foreground flex w-full cursor-pointer items-center gap-2 text-sm transition-colors'>
|
||||
<SearchIcon className='size-4' />
|
||||
<p className='text-sm'>{title}</p>
|
||||
<ChevronDownIcon className='size-4 transition-transform group-data-[state=open]:rotate-180' />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
|
||||
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>
|
||||
|
||||
export const TaskContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className='border-muted mt-4 space-y-2 border-l-2 pl-4'>
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import { type ComponentProps, isValidElement, type ReactNode } from 'react'
|
||||
import type { ToolUIPart } from 'ai'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { CodeBlock } from './code-block'
|
||||
|
||||
// Workaround for missing types in 'ai' package
|
||||
type ExtendedToolState =
|
||||
| ToolUIPart['state']
|
||||
| 'approval-requested'
|
||||
| 'approval-responded'
|
||||
| 'output-denied'
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>
|
||||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn('not-prose mb-4 w-full rounded-md border', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string
|
||||
type: ToolUIPart['type']
|
||||
state: ExtendedToolState
|
||||
className?: string
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: ExtendedToolState) => {
|
||||
const labels: Record<ExtendedToolState, string> = {
|
||||
'input-streaming': 'Pending',
|
||||
'input-available': 'Running',
|
||||
'approval-requested': 'Awaiting Approval',
|
||||
'approval-responded': 'Responded',
|
||||
'output-available': 'Completed',
|
||||
'output-error': 'Error',
|
||||
'output-denied': 'Denied',
|
||||
}
|
||||
|
||||
const icons: Record<ExtendedToolState, ReactNode> = {
|
||||
'input-streaming': <CircleIcon className='size-4' />,
|
||||
'input-available': <ClockIcon className='size-4 animate-pulse' />,
|
||||
'approval-requested': <ClockIcon className='size-4 text-yellow-600' />,
|
||||
'approval-responded': <CheckCircleIcon className='size-4 text-blue-600' />,
|
||||
'output-available': <CheckCircleIcon className='size-4 text-green-600' />,
|
||||
'output-error': <XCircleIcon className='size-4 text-red-600' />,
|
||||
'output-denied': <XCircleIcon className='size-4 text-orange-600' />,
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge className='gap-1.5 rounded-full text-xs' variant='secondary'>
|
||||
{icons[status]}
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export const ToolHeader = ({
|
||||
className,
|
||||
title,
|
||||
type,
|
||||
state,
|
||||
...props
|
||||
}: ToolHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-4 p-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<WrenchIcon className='text-muted-foreground size-4' />
|
||||
<span className='text-sm font-medium'>
|
||||
{title ?? type.split('-').slice(1).join('-')}
|
||||
</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className='text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-180' />
|
||||
</CollapsibleTrigger>
|
||||
)
|
||||
|
||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>
|
||||
|
||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type ToolInputProps = ComponentProps<'div'> & {
|
||||
input: ToolUIPart['input']
|
||||
}
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={cn('space-y-2 overflow-hidden p-4', className)} {...props}>
|
||||
<h4 className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
|
||||
{t('Parameters')}
|
||||
</h4>
|
||||
<div className='bg-muted/50 rounded-md'>
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language='json' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ToolOutputProps = ComponentProps<'div'> & {
|
||||
output: ToolUIPart['output']
|
||||
errorText: ToolUIPart['errorText']
|
||||
}
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let Output = <div>{output as ReactNode}</div>
|
||||
|
||||
if (typeof output === 'object' && !isValidElement(output)) {
|
||||
Output = (
|
||||
<CodeBlock code={JSON.stringify(output, null, 2)} language='json' />
|
||||
)
|
||||
} else if (typeof output === 'string') {
|
||||
Output = <CodeBlock code={output} language='json' />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2 p-4', className)} {...props}>
|
||||
<h4 className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
|
||||
{errorText ? 'Error' : 'Result'}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-x-auto rounded-md text-xs [&_table]:w-full',
|
||||
errorText
|
||||
? 'bg-destructive/10 text-destructive'
|
||||
: 'bg-muted/50 text-foreground'
|
||||
)}
|
||||
>
|
||||
{errorText && <div>{errorText}</div>}
|
||||
{Output}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { NodeToolbar, Position } from '@xyflow/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type ToolbarProps = ComponentProps<typeof NodeToolbar>
|
||||
|
||||
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
|
||||
<NodeToolbar
|
||||
className={cn(
|
||||
'bg-background flex items-center gap-1 rounded-sm border p-1.5',
|
||||
className
|
||||
)}
|
||||
position={Position.Bottom}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,274 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
export type WebPreviewContextValue = {
|
||||
url: string
|
||||
setUrl: (url: string) => void
|
||||
consoleOpen: boolean
|
||||
setConsoleOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null)
|
||||
|
||||
const useWebPreview = () => {
|
||||
const context = useContext(WebPreviewContext)
|
||||
if (!context) {
|
||||
throw new Error('WebPreview components must be used within a WebPreview')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type WebPreviewProps = ComponentProps<'div'> & {
|
||||
defaultUrl?: string
|
||||
onUrlChange?: (url: string) => void
|
||||
}
|
||||
|
||||
export const WebPreview = ({
|
||||
className,
|
||||
children,
|
||||
defaultUrl = '',
|
||||
onUrlChange,
|
||||
...props
|
||||
}: WebPreviewProps) => {
|
||||
const [url, setUrl] = useState(defaultUrl)
|
||||
const [consoleOpen, setConsoleOpen] = useState(false)
|
||||
|
||||
const handleUrlChange = (newUrl: string) => {
|
||||
setUrl(newUrl)
|
||||
onUrlChange?.(newUrl)
|
||||
}
|
||||
|
||||
const contextValue: WebPreviewContextValue = {
|
||||
url,
|
||||
setUrl: handleUrlChange,
|
||||
consoleOpen,
|
||||
setConsoleOpen,
|
||||
}
|
||||
|
||||
return (
|
||||
<WebPreviewContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-card flex size-full flex-col rounded-lg border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</WebPreviewContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export type WebPreviewNavigationProps = ComponentProps<'div'>
|
||||
|
||||
export const WebPreviewNavigation = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationProps) => (
|
||||
<div
|
||||
className={cn('flex items-center gap-1 border-b p-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export const WebPreviewNavigationButton = ({
|
||||
onClick,
|
||||
disabled,
|
||||
tooltip,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationButtonProps) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className='hover:text-foreground h-8 w-8 p-0'
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
export type WebPreviewUrlProps = ComponentProps<typeof Input>
|
||||
|
||||
export const WebPreviewUrl = ({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: WebPreviewUrlProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { url, setUrl } = useWebPreview()
|
||||
const [inputValue, setInputValue] = useState(url)
|
||||
|
||||
// Sync input value with context URL when it changes externally
|
||||
useEffect(() => {
|
||||
setInputValue(url)
|
||||
}, [url])
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value)
|
||||
onChange?.(event)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
const target = event.target as HTMLInputElement
|
||||
setUrl(target.value)
|
||||
}
|
||||
onKeyDown?.(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
className='h-8 flex-1 text-sm'
|
||||
onChange={onChange ?? handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('Enter URL...')}
|
||||
value={value ?? inputValue}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type WebPreviewBodyProps = ComponentProps<'iframe'> & {
|
||||
loading?: ReactNode
|
||||
}
|
||||
|
||||
export const WebPreviewBody = ({
|
||||
className,
|
||||
loading,
|
||||
src,
|
||||
...props
|
||||
}: WebPreviewBodyProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { url } = useWebPreview()
|
||||
|
||||
return (
|
||||
<div className='flex-1'>
|
||||
<iframe
|
||||
className={cn('size-full', className)}
|
||||
sandbox='allow-scripts allow-same-origin allow-forms allow-popups allow-presentation'
|
||||
src={(src ?? url) || undefined}
|
||||
title={t('Preview')}
|
||||
{...props}
|
||||
/>
|
||||
{loading}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type WebPreviewConsoleProps = ComponentProps<'div'> & {
|
||||
logs?: Array<{
|
||||
level: 'log' | 'warn' | 'error'
|
||||
message: string
|
||||
timestamp: Date
|
||||
}>
|
||||
}
|
||||
|
||||
export const WebPreviewConsole = ({
|
||||
className,
|
||||
logs = [],
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewConsoleProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { consoleOpen, setConsoleOpen } = useWebPreview()
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn('bg-muted/50 border-t font-mono text-sm', className)}
|
||||
onOpenChange={setConsoleOpen}
|
||||
open={consoleOpen}
|
||||
{...props}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className='hover:bg-muted/50 flex w-full items-center justify-between p-4 text-left font-medium'
|
||||
variant='ghost'
|
||||
>
|
||||
{t('Console')}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'h-4 w-4 transition-transform duration-200',
|
||||
consoleOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'px-4 pb-4',
|
||||
'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 data-[state=closed]:animate-out data-[state=open]:animate-in outline-none'
|
||||
)}
|
||||
>
|
||||
<div className='max-h-48 space-y-1 overflow-y-auto'>
|
||||
{logs.length === 0 ? (
|
||||
<p className='text-muted-foreground'>{t('No console output')}</p>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs',
|
||||
log.level === 'error' && 'text-destructive',
|
||||
log.level === 'warn' && 'text-yellow-600',
|
||||
log.level === 'log' && 'text-foreground'
|
||||
)}
|
||||
key={`${log.timestamp.getTime()}-${index}`}
|
||||
>
|
||||
<span className='text-muted-foreground'>
|
||||
{dayjs(log.timestamp).format('HH:mm:ss')}
|
||||
</span>{' '}
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { useRef, useEffect, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AnimateInViewProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
delay?: number
|
||||
threshold?: number
|
||||
animation?: 'fade-up' | 'fade-in' | 'scale-in' | 'fade-left' | 'fade-right'
|
||||
once?: boolean
|
||||
as?: 'div' | 'section' | 'li' | 'span'
|
||||
}
|
||||
|
||||
export function AnimateInView(props: AnimateInViewProps) {
|
||||
const {
|
||||
as: Tag = 'div',
|
||||
delay = 0,
|
||||
threshold = 0.15,
|
||||
animation = 'fade-up',
|
||||
once = true,
|
||||
} = props
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
if (mq.matches) {
|
||||
el.classList.remove('opacity-0')
|
||||
el.classList.add(`landing-animate-${animation}`)
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
el.classList.remove('opacity-0')
|
||||
el.classList.add(`landing-animate-${animation}`)
|
||||
if (once) observer.unobserve(el)
|
||||
} else if (!once) {
|
||||
el.classList.add('opacity-0')
|
||||
el.classList.remove(`landing-animate-${animation}`)
|
||||
}
|
||||
},
|
||||
{ threshold, rootMargin: '0px 0px -40px 0px' }
|
||||
)
|
||||
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [threshold, once, animation])
|
||||
|
||||
return (
|
||||
<Tag
|
||||
ref={ref as never}
|
||||
className={cn(
|
||||
'opacity-0 will-change-[transform,opacity]',
|
||||
props.className
|
||||
)}
|
||||
style={{ animationDelay: delay ? `${delay}ms` : undefined }}
|
||||
>
|
||||
{props.children}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import { AutoSkeleton } from 'auto-skeleton-react'
|
||||
import { ErrorState } from '@/components/error-state'
|
||||
|
||||
interface ContentSkeletonProps {
|
||||
loading: boolean
|
||||
children: ReactNode
|
||||
borderRadius?: number
|
||||
minTextHeight?: number
|
||||
maxDepth?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ContentSkeleton(props: ContentSkeletonProps) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<AutoSkeleton
|
||||
loading={props.loading}
|
||||
config={{
|
||||
animation: 'none',
|
||||
baseColor: 'var(--skeleton-base)',
|
||||
highlightColor: 'var(--skeleton-highlight)',
|
||||
borderRadius: props.borderRadius ?? 6,
|
||||
minTextHeight: props.minTextHeight ?? 14,
|
||||
maxDepth: props.maxDepth ?? 10,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</AutoSkeleton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface QuerySkeletonProps {
|
||||
query: UseQueryResult<unknown, unknown>
|
||||
children: ReactNode
|
||||
className?: string
|
||||
errorTitle?: string
|
||||
errorDescription?: string
|
||||
}
|
||||
|
||||
export function QuerySkeleton(props: QuerySkeletonProps) {
|
||||
if (props.query.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title={props.errorTitle}
|
||||
description={props.errorDescription}
|
||||
onRetry={() => props.query.refetch()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentSkeleton
|
||||
loading={props.query.isLoading}
|
||||
className={props.className}
|
||||
>
|
||||
{props.children}
|
||||
</ContentSkeleton>
|
||||
)
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { Telescope } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function ComingSoon() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='h-svh'>
|
||||
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
|
||||
<Telescope size={72} />
|
||||
<h1 className='text-4xl leading-tight font-bold'>
|
||||
{t('Coming Soon!')}
|
||||
</h1>
|
||||
<p className='text-muted-foreground text-center'>
|
||||
{t('This page has not been created yet.')} <br />
|
||||
{t('Stay tuned though!')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import React from 'react'
|
||||
import { useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSearch } from '@/context/search-provider'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { useSidebarData } from '@/hooks/use-sidebar-data'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import { getNavGroupsForPath } from './layout/lib/workspace-registry'
|
||||
import { ScrollArea } from './ui/scroll-area'
|
||||
|
||||
export function CommandMenu() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { setTheme } = useTheme()
|
||||
const { open, setOpen } = useSearch()
|
||||
const { pathname } = useLocation()
|
||||
const sidebarData = useSidebarData()
|
||||
|
||||
// 根据当前路径从工作区注册表获取对应的侧边栏配置
|
||||
const navGroups = getNavGroupsForPath(pathname, t) || sidebarData.navGroups
|
||||
|
||||
const runCommand = React.useCallback(
|
||||
(command: () => unknown) => {
|
||||
setOpen(false)
|
||||
command()
|
||||
},
|
||||
[setOpen]
|
||||
)
|
||||
|
||||
return (
|
||||
<CommandDialog modal open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder={t('Type a command or search...')} />
|
||||
<CommandList>
|
||||
<ScrollArea type='hover' className='h-72 pe-1'>
|
||||
<CommandEmpty>{t('No results found.')}</CommandEmpty>
|
||||
{navGroups.map((group) => (
|
||||
<CommandGroup key={group.id || group.title} heading={group.title}>
|
||||
{group.items.map((navItem, i) => {
|
||||
if (navItem.url)
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${navItem.url}-${i}`}
|
||||
value={navItem.title}
|
||||
onSelect={() => {
|
||||
runCommand(() => navigate({ to: navItem.url }))
|
||||
}}
|
||||
>
|
||||
<div className='flex size-4 items-center justify-center'>
|
||||
<ArrowRight className='text-muted-foreground/80 size-2' />
|
||||
</div>
|
||||
{navItem.title}
|
||||
</CommandItem>
|
||||
)
|
||||
|
||||
return navItem.items?.map((subItem, i) => (
|
||||
<CommandItem
|
||||
key={`${navItem.title}-${subItem.url}-${i}`}
|
||||
value={`${navItem.title}-${subItem.url}`}
|
||||
onSelect={() => {
|
||||
runCommand(() => navigate({ to: subItem.url }))
|
||||
}}
|
||||
>
|
||||
<div className='flex size-4 items-center justify-center'>
|
||||
<ArrowRight className='text-muted-foreground/80 size-2' />
|
||||
</div>
|
||||
{navItem.title} <ChevronRight /> {subItem.title}
|
||||
</CommandItem>
|
||||
))
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading='Theme'>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
|
||||
<Sun /> <span>{t('Light')}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
|
||||
<Moon className='scale-90' />
|
||||
<span>{t('Dark')}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('system'))}>
|
||||
<Laptop />
|
||||
<span>{t('System')}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { Root as Radio, Item } from '@radix-ui/react-radio-group'
|
||||
import { CircleCheck, RotateCcw, Palette } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { IconDir } from '@/assets/custom/icon-dir'
|
||||
import { IconLayoutCompact } from '@/assets/custom/icon-layout-compact'
|
||||
import { IconLayoutDefault } from '@/assets/custom/icon-layout-default'
|
||||
import { IconLayoutFull } from '@/assets/custom/icon-layout-full'
|
||||
import { IconSidebarFloating } from '@/assets/custom/icon-sidebar-floating'
|
||||
import { IconSidebarInset } from '@/assets/custom/icon-sidebar-inset'
|
||||
import { IconSidebarSidebar } from '@/assets/custom/icon-sidebar-sidebar'
|
||||
import { IconThemeDark } from '@/assets/custom/icon-theme-dark'
|
||||
import { IconThemeLight } from '@/assets/custom/icon-theme-light'
|
||||
import { IconThemeSystem } from '@/assets/custom/icon-theme-system'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useDirection } from '@/context/direction-provider'
|
||||
import { type Collapsible, useLayout } from '@/context/layout-provider'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import { useSidebar } from './ui/sidebar'
|
||||
|
||||
export function ConfigDrawer() {
|
||||
const { t } = useTranslation()
|
||||
const { setOpen } = useSidebar()
|
||||
const { resetDir } = useDirection()
|
||||
const { resetTheme } = useTheme()
|
||||
const { resetLayout } = useLayout()
|
||||
|
||||
const handleReset = () => {
|
||||
setOpen(true)
|
||||
resetDir()
|
||||
resetTheme()
|
||||
resetLayout()
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
aria-label={t('Open theme settings')}
|
||||
aria-describedby='config-drawer-description'
|
||||
className='rounded-full max-md:hidden'
|
||||
>
|
||||
<Palette className='size-[1.2rem]' aria-hidden='true' />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='flex w-full flex-col sm:max-w-md'>
|
||||
<SheetHeader className='pb-0 text-start'>
|
||||
<SheetTitle>{t('Theme Settings')}</SheetTitle>
|
||||
<SheetDescription id='config-drawer-description'>
|
||||
{t('Adjust the appearance and layout to suit your preferences.')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='space-y-6 overflow-y-auto px-4'>
|
||||
<ThemeConfig />
|
||||
<SidebarConfig />
|
||||
<LayoutConfig />
|
||||
<DirConfig />
|
||||
</div>
|
||||
<SheetFooter className='gap-2'>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleReset}
|
||||
aria-label={t('Reset all settings to default values')}
|
||||
>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({
|
||||
title,
|
||||
showReset = false,
|
||||
onReset,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
showReset?: boolean
|
||||
onReset?: () => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground mb-2 flex items-center gap-2 text-sm font-semibold',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
{showReset && onReset && (
|
||||
<Button
|
||||
size='icon'
|
||||
variant='secondary'
|
||||
className='size-4 rounded-full'
|
||||
onClick={onReset}
|
||||
aria-label='Reset'
|
||||
>
|
||||
<RotateCcw className='size-3' aria-hidden='true' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
item,
|
||||
isTheme = false,
|
||||
}: {
|
||||
item: {
|
||||
value: string
|
||||
label: string
|
||||
icon: (props: SVGProps<SVGSVGElement>) => React.ReactElement
|
||||
}
|
||||
isTheme?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Item
|
||||
value={item.value}
|
||||
className={cn('group outline-none', 'transition duration-200 ease-in')}
|
||||
aria-label={`Select ${item.label.toLowerCase()}`}
|
||||
aria-describedby={`${item.value}-description`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'ring-border relative rounded-[6px] ring-[1px]',
|
||||
'group-data-[state=checked]:ring-primary group-data-[state=checked]:shadow-2xl',
|
||||
'group-focus-visible:ring-2'
|
||||
)}
|
||||
role='img'
|
||||
aria-hidden='false'
|
||||
aria-label={`${item.label} option preview`}
|
||||
>
|
||||
<CircleCheck
|
||||
className={cn(
|
||||
'fill-primary size-6 stroke-white',
|
||||
'group-data-[state=unchecked]:hidden',
|
||||
'absolute top-0 right-0 translate-x-1/2 -translate-y-1/2'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<item.icon
|
||||
className={cn(
|
||||
!isTheme &&
|
||||
'stroke-primary fill-primary group-data-[state=unchecked]:stroke-muted-foreground group-data-[state=unchecked]:fill-muted-foreground'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className='mt-1 text-xs'
|
||||
id={`${item.value}-description`}
|
||||
aria-live='polite'
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { defaultTheme, theme, setTheme } = useTheme()
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
title={t('Theme')}
|
||||
showReset={theme !== defaultTheme}
|
||||
onReset={() => setTheme(defaultTheme)}
|
||||
/>
|
||||
<Radio
|
||||
value={theme}
|
||||
onValueChange={setTheme}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label={t('Select theme preference')}
|
||||
aria-describedby='theme-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'system',
|
||||
label: 'System',
|
||||
icon: IconThemeSystem,
|
||||
},
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Light',
|
||||
icon: IconThemeLight,
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Dark',
|
||||
icon: IconThemeDark,
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} isTheme />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='theme-description' className='sr-only'>
|
||||
{t('Choose between system preference, light mode, or dark mode')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { defaultVariant, variant, setVariant } = useLayout()
|
||||
return (
|
||||
<div className='max-md:hidden'>
|
||||
<SectionTitle
|
||||
title={t('Sidebar')}
|
||||
showReset={defaultVariant !== variant}
|
||||
onReset={() => setVariant(defaultVariant)}
|
||||
/>
|
||||
<Radio
|
||||
value={variant}
|
||||
onValueChange={setVariant}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label={t('Select sidebar style')}
|
||||
aria-describedby='sidebar-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'inset',
|
||||
label: 'Inset',
|
||||
icon: IconSidebarInset,
|
||||
},
|
||||
{
|
||||
value: 'floating',
|
||||
label: 'Floating',
|
||||
icon: IconSidebarFloating,
|
||||
},
|
||||
{
|
||||
value: 'sidebar',
|
||||
label: 'Sidebar',
|
||||
icon: IconSidebarSidebar,
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='sidebar-description' className='sr-only'>
|
||||
{t('Choose between inset, floating, or standard sidebar layout')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { open, setOpen } = useSidebar()
|
||||
const { defaultCollapsible, collapsible, setCollapsible } = useLayout()
|
||||
|
||||
const radioState = open ? 'default' : collapsible
|
||||
|
||||
return (
|
||||
<div className='max-md:hidden'>
|
||||
<SectionTitle
|
||||
title={t('Layout')}
|
||||
showReset={radioState !== 'default'}
|
||||
onReset={() => {
|
||||
setOpen(true)
|
||||
setCollapsible(defaultCollapsible)
|
||||
}}
|
||||
/>
|
||||
<Radio
|
||||
value={radioState}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'default') {
|
||||
setOpen(true)
|
||||
return
|
||||
}
|
||||
setOpen(false)
|
||||
setCollapsible(v as Collapsible)
|
||||
}}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label={t('Select layout style')}
|
||||
aria-describedby='layout-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Default',
|
||||
icon: IconLayoutDefault,
|
||||
},
|
||||
{
|
||||
value: 'icon',
|
||||
label: 'Compact',
|
||||
icon: IconLayoutCompact,
|
||||
},
|
||||
{
|
||||
value: 'offcanvas',
|
||||
label: 'Full layout',
|
||||
icon: IconLayoutFull,
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='layout-description' className='sr-only'>
|
||||
{t(
|
||||
'Choose between default expanded, compact icon-only, or full layout mode'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DirConfig() {
|
||||
const { t } = useTranslation()
|
||||
const { defaultDir, dir, setDir } = useDirection()
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
title={t('Direction')}
|
||||
showReset={defaultDir !== dir}
|
||||
onReset={() => setDir(defaultDir)}
|
||||
/>
|
||||
<Radio
|
||||
value={dir}
|
||||
onValueChange={setDir}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label={t('Select site direction')}
|
||||
aria-describedby='direction-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'ltr',
|
||||
label: 'Left to Right',
|
||||
icon: (props: SVGProps<SVGSVGElement>) => (
|
||||
<IconDir dir='ltr' {...props} />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'rtl',
|
||||
label: 'Right to Left',
|
||||
icon: (props: SVGProps<SVGSVGElement>) => (
|
||||
<IconDir dir='rtl' {...props} />
|
||||
),
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='direction-description' className='sr-only'>
|
||||
{t('Choose between left-to-right or right-to-left site direction')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: React.ReactNode
|
||||
disabled?: boolean
|
||||
desc: React.JSX.Element | string
|
||||
cancelBtnText?: string
|
||||
confirmText?: React.ReactNode
|
||||
destructive?: boolean
|
||||
handleConfirm: () => void
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
title,
|
||||
desc,
|
||||
children,
|
||||
className,
|
||||
confirmText,
|
||||
cancelBtnText,
|
||||
destructive,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
handleConfirm,
|
||||
...actions
|
||||
} = props
|
||||
return (
|
||||
<AlertDialog {...actions}>
|
||||
<AlertDialogContent className={cn(className && className)}>
|
||||
<AlertDialogHeader className='text-start'>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>{desc}</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{children}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>
|
||||
{cancelBtnText ?? t('Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={handleConfirm}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
{confirmText ?? t('Continue')}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
interface CopyButtonProps {
|
||||
value: string
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
variant?: 'ghost' | 'outline' | 'default' | 'secondary' | 'destructive'
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||
tooltip?: string
|
||||
successTooltip?: string
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
iconClassName,
|
||||
variant = 'ghost',
|
||||
size = 'icon',
|
||||
tooltip,
|
||||
successTooltip,
|
||||
'aria-label': ariaLabel,
|
||||
}: CopyButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
|
||||
const isCopied = copiedText === value
|
||||
const resolvedTooltip = tooltip ?? t('Copy to clipboard')
|
||||
const resolvedSuccessTooltip = successTooltip ?? t('Copied!')
|
||||
const resolvedAriaLabel = ariaLabel ?? resolvedTooltip
|
||||
const copiedAriaLabel = t('Copied')
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn('shrink-0', className)}
|
||||
onClick={() => copyToClipboard(value)}
|
||||
aria-label={isCopied ? copiedAriaLabel : resolvedAriaLabel}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className={cn('text-green-600', iconClassName)} />
|
||||
) : (
|
||||
<Copy className={cn(iconClassName)} />
|
||||
)}
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (tooltip || successTooltip) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{isCopied ? resolvedSuccessTooltip : resolvedTooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
type DataTableBulkActionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
entityName: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A modular toolbar for displaying bulk actions when table rows are selected.
|
||||
*
|
||||
* @template TData The type of data in the table.
|
||||
* @param {object} props The component props.
|
||||
* @param {Table<TData>} props.table The react-table instance.
|
||||
* @param {string} props.entityName The name of the entity being acted upon (e.g., "task", "user").
|
||||
* @param {React.ReactNode} props.children The action buttons to be rendered inside the toolbar.
|
||||
* @returns {React.ReactNode | null} The rendered component or null if no rows are selected.
|
||||
*/
|
||||
export function DataTableBulkActions<TData>({
|
||||
table,
|
||||
entityName,
|
||||
children,
|
||||
}: DataTableBulkActionsProps<TData>): React.ReactNode | null {
|
||||
const { t } = useTranslation()
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
const selectedCount = selectedRows.length
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
|
||||
// Announce selection changes to screen readers
|
||||
useEffect(() => {
|
||||
if (selectedCount > 0) {
|
||||
const message = `${selectedCount} ${entityName}${selectedCount > 1 ? 's' : ''} selected. Bulk actions toolbar is available.`
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAnnouncement(message)
|
||||
|
||||
// Clear announcement after a delay
|
||||
const timer = setTimeout(() => setAnnouncement(''), 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [selectedCount, entityName])
|
||||
|
||||
const handleClearSelection = () => {
|
||||
table.resetRowSelection()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const buttons = toolbarRef.current?.querySelectorAll('button')
|
||||
if (!buttons) return
|
||||
|
||||
const currentIndex = Array.from(buttons).findIndex(
|
||||
(button) => button === document.activeElement
|
||||
)
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowRight': {
|
||||
event.preventDefault()
|
||||
const nextIndex = (currentIndex + 1) % buttons.length
|
||||
buttons[nextIndex]?.focus()
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
event.preventDefault()
|
||||
const prevIndex =
|
||||
currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
|
||||
buttons[prevIndex]?.focus()
|
||||
break
|
||||
}
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
buttons[0]?.focus()
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
buttons[buttons.length - 1]?.focus()
|
||||
break
|
||||
case 'Escape': {
|
||||
// Check if the Escape key came from a dropdown trigger or content
|
||||
// We can't check dropdown state because Radix UI closes it before our handler runs
|
||||
const target = event.target as HTMLElement
|
||||
const activeElement = document.activeElement as HTMLElement
|
||||
|
||||
// Check if the event target or currently focused element is a dropdown trigger
|
||||
const isFromDropdownTrigger =
|
||||
target?.getAttribute('data-slot') === 'dropdown-menu-trigger' ||
|
||||
activeElement?.getAttribute('data-slot') ===
|
||||
'dropdown-menu-trigger' ||
|
||||
target?.closest('[data-slot="dropdown-menu-trigger"]') ||
|
||||
activeElement?.closest('[data-slot="dropdown-menu-trigger"]')
|
||||
|
||||
// Check if the focused element is inside dropdown content (which is portaled)
|
||||
const isFromDropdownContent =
|
||||
activeElement?.closest('[data-slot="dropdown-menu-content"]') ||
|
||||
target?.closest('[data-slot="dropdown-menu-content"]')
|
||||
|
||||
if (isFromDropdownTrigger || isFromDropdownContent) {
|
||||
// Escape was meant for the dropdown - don't clear selection
|
||||
return
|
||||
}
|
||||
|
||||
// Escape was meant for the toolbar - clear selection
|
||||
event.preventDefault()
|
||||
handleClearSelection()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Live region for screen reader announcements */}
|
||||
<div
|
||||
aria-live='polite'
|
||||
aria-atomic='true'
|
||||
className='sr-only'
|
||||
role='status'
|
||||
>
|
||||
{announcement}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
role='toolbar'
|
||||
aria-label={`Bulk actions for ${selectedCount} selected ${entityName}${selectedCount > 1 ? 's' : ''}`}
|
||||
aria-describedby='bulk-actions-description'
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
|
||||
'transition-all delay-100 duration-300 ease-out hover:scale-105',
|
||||
'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 shadow-xl',
|
||||
'rounded-xl border',
|
||||
'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg',
|
||||
'flex items-center gap-x-2'
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleClearSelection}
|
||||
className='size-6 rounded-full'
|
||||
aria-label={t('Clear selection')}
|
||||
title={t('Clear selection (Escape)')}
|
||||
>
|
||||
<X />
|
||||
<span className='sr-only'>{t('Clear selection')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Clear selection (Escape)')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator
|
||||
className='h-5'
|
||||
orientation='vertical'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
<div
|
||||
className='flex items-center gap-x-1 text-sm'
|
||||
id='bulk-actions-description'
|
||||
>
|
||||
<Badge
|
||||
variant='default'
|
||||
className='min-w-8 rounded-lg'
|
||||
aria-label={`${selectedCount} selected`}
|
||||
>
|
||||
{selectedCount}
|
||||
</Badge>{' '}
|
||||
<span className='hidden sm:inline'>
|
||||
{entityName}
|
||||
{selectedCount > 1 ? 's' : ''}
|
||||
</span>{' '}
|
||||
{t('selected')}
|
||||
</div>
|
||||
|
||||
<Separator
|
||||
className='h-5'
|
||||
orientation='vertical'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CaretSortIcon,
|
||||
EyeNoneIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { type Column } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
type DataTableColumnHeaderProps<TData, TValue> =
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
column: Column<TData, TValue>
|
||||
title: string
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
const { t } = useTranslation()
|
||||
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='data-[state=open]:bg-accent -ms-3 h-8'
|
||||
>
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDownIcon className='ms-2 h-4 w-4' />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUpIcon className='ms-2 h-4 w-4' />
|
||||
) : (
|
||||
<CaretSortIcon className='ms-2 h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className='text-muted-foreground/70 size-3.5' />
|
||||
{t('Asc')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className='text-muted-foreground/70 size-3.5' />
|
||||
{t('Desc')}
|
||||
</DropdownMenuItem>
|
||||
{column.getCanHide() && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className='text-muted-foreground/70 size-3.5' />
|
||||
{t('Hide')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import * as React from 'react'
|
||||
import { CheckIcon, PlusCircledIcon } from '@radix-ui/react-icons'
|
||||
import { type Column } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
type DataTableFacetedFilterProps<TData, TValue> = {
|
||||
column?: Column<TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
/** Enable single select mode (only one option can be selected at a time) */
|
||||
singleSelect?: boolean
|
||||
}
|
||||
|
||||
export function DataTableFacetedFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
singleSelect = false,
|
||||
}: DataTableFacetedFilterProps<TData, TValue>) {
|
||||
const { t } = useTranslation()
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const filterValue = column?.getFilterValue() as string[] | undefined
|
||||
const selectedValues = new Set(filterValue)
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' size='sm' className='h-8 border-dashed'>
|
||||
<PlusCircledIcon className='size-4' />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation='vertical' className='mx-2 h-4' />
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='rounded-sm px-1 font-normal lg:hidden'
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className='hidden space-x-1 lg:flex'>
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='rounded-sm px-1 font-normal'
|
||||
>
|
||||
{selectedValues.size} {t('selected')}
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
key={option.value}
|
||||
className='rounded-sm px-1 font-normal'
|
||||
>
|
||||
{t(option.label)}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[200px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('No results found.')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
if (singleSelect) {
|
||||
// Single select mode: toggle or switch selection
|
||||
if (isSelected) {
|
||||
// Deselect if clicking the same option
|
||||
column?.setFilterValue(undefined)
|
||||
} else {
|
||||
// Select only this option
|
||||
column?.setFilterValue([option.value])
|
||||
}
|
||||
} else {
|
||||
// Multi-select mode: original behavior
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'border-primary flex size-4 items-center justify-center rounded-sm border',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
)}
|
||||
>
|
||||
<CheckIcon className={cn('text-background h-4 w-4')} />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className='text-muted-foreground size-4' />
|
||||
)}
|
||||
<span>{t(option.label)}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className='ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs'>
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => column?.setFilterValue(undefined)}
|
||||
className='justify-center text-center'
|
||||
>
|
||||
{t('Clear filters')}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { DataTablePagination } from './pagination'
|
||||
export { DataTableColumnHeader } from './column-header'
|
||||
export { DataTableFacetedFilter } from './faceted-filter'
|
||||
export { DataTableViewOptions } from './view-options'
|
||||
export { DataTableToolbar } from './toolbar'
|
||||
export { DataTableBulkActions } from './bulk-actions'
|
||||
export { TableSkeleton } from './table-skeleton'
|
||||
export { TableEmpty } from './table-empty'
|
||||
export { MobileCardList } from './mobile-card-list'
|
||||
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
flexRender,
|
||||
type Cell,
|
||||
type Row,
|
||||
type Table,
|
||||
} from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface MobileCardListProps<TData> {
|
||||
table: Table<TData>
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
getRowKey?: (row: Row<TData>) => string | number
|
||||
}
|
||||
|
||||
interface MobileColumnMeta {
|
||||
label?: string
|
||||
mobileTitle?: boolean
|
||||
mobileBadge?: boolean
|
||||
mobileHidden?: boolean
|
||||
}
|
||||
|
||||
function getCellMeta<TData>(
|
||||
cell: Cell<TData, unknown>
|
||||
): MobileColumnMeta | undefined {
|
||||
return cell.column.columnDef.meta as MobileColumnMeta | undefined
|
||||
}
|
||||
|
||||
function getCellLabel<TData>(cell: Cell<TData, unknown>): string | null {
|
||||
const meta = getCellMeta(cell)
|
||||
if (meta?.label) return meta.label
|
||||
const header = cell.column.columnDef.header
|
||||
return typeof header === 'string' ? header : null
|
||||
}
|
||||
|
||||
function renderCellContent<TData>(cell: Cell<TData, unknown>): React.ReactNode {
|
||||
const cellRenderer = cell.column.columnDef.cell
|
||||
if (cellRenderer) {
|
||||
return flexRender(cellRenderer, cell.getContext())
|
||||
}
|
||||
return cell.getValue() as React.ReactNode
|
||||
}
|
||||
|
||||
function ListSkeleton() {
|
||||
return (
|
||||
<div className='divide-y overflow-hidden rounded-lg border'>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className='px-3 py-2.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-32' />
|
||||
<Skeleton className='h-5 w-16 rounded-full' />
|
||||
</div>
|
||||
<div className='mt-1.5 flex items-start gap-4'>
|
||||
<div className='flex-1'>
|
||||
<Skeleton className='mb-1 h-2 w-8' />
|
||||
<Skeleton className='h-4 w-full' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<Skeleton className='mb-1 h-2 w-8' />
|
||||
<Skeleton className='h-4 w-full' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FallbackListSkeleton() {
|
||||
return (
|
||||
<div className='divide-y overflow-hidden rounded-lg border'>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className='space-y-1.5 px-3 py-2.5'>
|
||||
{[1, 2, 3].map((j) => (
|
||||
<div key={j} className='flex items-center justify-between'>
|
||||
<Skeleton className='h-2.5 w-16' />
|
||||
<Skeleton className='h-3.5 w-28' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact list row — structured layout with title header + side-by-side fields.
|
||||
* Used when columns define mobileTitle or mobileBadge meta.
|
||||
*
|
||||
* Visual structure per row:
|
||||
* [Title content] [Badge]
|
||||
* [Field1 label] [Field2 label]
|
||||
* [Field1 value] [Field2 value]
|
||||
* [Actions ⋯]
|
||||
*/
|
||||
function CompactRow<TData>({ row }: { row: Row<TData> }) {
|
||||
const allCells = row
|
||||
.getVisibleCells()
|
||||
.filter((cell) => cell.column.id !== 'select')
|
||||
|
||||
const titleCell = allCells.find((c) => getCellMeta(c)?.mobileTitle)
|
||||
const badgeCell = allCells.find((c) => getCellMeta(c)?.mobileBadge)
|
||||
const actionsCell = allCells.find((c) => c.column.id === 'actions')
|
||||
|
||||
const fieldCells = allCells.filter(
|
||||
(c) =>
|
||||
c !== titleCell &&
|
||||
c !== badgeCell &&
|
||||
c !== actionsCell &&
|
||||
!getCellMeta(c)?.mobileHidden
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Row 1: Title + Badge */}
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
{titleCell && (
|
||||
<div className='min-w-0 flex-1 overflow-hidden text-sm font-medium'>
|
||||
{renderCellContent(titleCell)}
|
||||
</div>
|
||||
)}
|
||||
{badgeCell && (
|
||||
<div className='shrink-0'>{renderCellContent(badgeCell)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Key fields side by side */}
|
||||
{fieldCells.length > 0 && (
|
||||
<div className='mt-1.5 flex items-start gap-4'>
|
||||
{fieldCells.map((cell) => {
|
||||
const label = getCellLabel(cell)
|
||||
return (
|
||||
<div key={cell.id} className='min-w-0 flex-1 overflow-hidden'>
|
||||
{label && (
|
||||
<div className='text-muted-foreground mb-0.5 text-[10px] leading-none select-none'>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
<div className='min-w-0 overflow-hidden text-xs'>
|
||||
{renderCellContent(cell) ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{actionsCell && (
|
||||
<div className='mt-1 -mb-0.5 flex justify-end'>
|
||||
{renderCellContent(actionsCell)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback list row — condensed label:value pairs for tables without
|
||||
* mobileTitle/mobileBadge. Still respects mobileHidden.
|
||||
*/
|
||||
function FallbackRow<TData>({ row }: { row: Row<TData> }) {
|
||||
const allCells = row
|
||||
.getVisibleCells()
|
||||
.filter((cell) => cell.column.id !== 'select')
|
||||
|
||||
const actionsCell = allCells.find((c) => c.column.id === 'actions')
|
||||
const contentCells = allCells.filter(
|
||||
(c) => c.column.id !== 'actions' && !getCellMeta(c)?.mobileHidden
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{contentCells.map((cell) => {
|
||||
const label = getCellLabel(cell)
|
||||
const content = renderCellContent(cell)
|
||||
|
||||
if (!label) {
|
||||
return (
|
||||
<div key={cell.id} className='flex justify-end overflow-hidden'>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className='flex items-start justify-between gap-2 overflow-hidden'
|
||||
>
|
||||
<span className='text-muted-foreground shrink-0 text-[10px] font-medium select-none'>
|
||||
{label}
|
||||
</span>
|
||||
<div className='flex min-w-0 flex-1 items-center justify-end overflow-hidden text-xs'>
|
||||
{content ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{actionsCell && (
|
||||
<div className='-mb-0.5 flex justify-end pt-0.5'>
|
||||
{renderCellContent(actionsCell)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile-optimized list view for table data.
|
||||
*
|
||||
* Renders rows inside a single bordered container with dividers —
|
||||
* a Vercel/Stripe-style list rather than individual cards.
|
||||
*
|
||||
* Column meta extensions:
|
||||
* - `mobileTitle` — card header (left, larger text)
|
||||
* - `mobileBadge` — inline with title (right, e.g. status badge)
|
||||
* - `mobileHidden` — hidden on mobile
|
||||
*
|
||||
* When mobileTitle or mobileBadge is set on any column, uses a structured
|
||||
* two-tier layout: title+badge header, then 2 key fields side-by-side.
|
||||
* Otherwise falls back to a condensed single-column label:value list.
|
||||
*/
|
||||
export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
|
||||
const {
|
||||
table,
|
||||
isLoading = false,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
getRowKey,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
|
||||
const resolvedEmptyTitle = emptyTitle ?? t('No Data')
|
||||
const resolvedEmptyDescription = emptyDescription ?? t('No data available')
|
||||
|
||||
const hasCompactMeta = table.getVisibleLeafColumns().some((col) => {
|
||||
const meta = col.columnDef.meta as MobileColumnMeta | undefined
|
||||
return meta?.mobileTitle || meta?.mobileBadge
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return hasCompactMeta ? <ListSkeleton /> : <FallbackListSkeleton />
|
||||
}
|
||||
|
||||
const rows = table.getRowModel().rows
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return (
|
||||
<div className='rounded-lg border p-8'>
|
||||
<Empty className='border-none p-0'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Database className='size-6' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RowComponent = hasCompactMeta ? CompactRow : FallbackRow
|
||||
|
||||
return (
|
||||
<div className='divide-y overflow-hidden rounded-lg border'>
|
||||
{rows.map((row) => {
|
||||
const key = getRowKey ? getRowKey(row) : row.id
|
||||
return (
|
||||
<div key={key} className='bg-card px-3 py-2.5'>
|
||||
<RowComponent row={row} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DoubleArrowLeftIcon,
|
||||
DoubleArrowRightIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn, getPageNumbers } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
type DataTablePaginationProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const currentPage = table.getState().pagination.pageIndex + 1
|
||||
const totalPages = table.getPageCount()
|
||||
const pageNumbers = getPageNumbers(currentPage, totalPages)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between overflow-clip',
|
||||
'@max-2xl/content:flex-col-reverse @max-2xl/content:gap-4'
|
||||
)}
|
||||
style={{ overflowClipMargin: 1 }}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div className='flex min-w-[130px] items-center text-sm font-medium whitespace-nowrap @2xl/content:hidden'>
|
||||
{t('Page {{current}} of {{total}}', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
})}
|
||||
</div>
|
||||
<div className='flex items-center gap-2 @max-2xl/content:flex-row-reverse'>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[70px]'>
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top'>
|
||||
{[10, 20, 30, 40, 50, 100].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='hidden text-sm font-medium sm:block'>
|
||||
{t('Rows per page')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center sm:space-x-6 lg:space-x-8'>
|
||||
<div className='flex min-w-[130px] items-center text-sm font-medium whitespace-nowrap @max-3xl/content:hidden'>
|
||||
{t('Page {{current}} of {{total}}', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
})}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0 @max-md/content:hidden'
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>{t('Go to first page')}</span>
|
||||
<DoubleArrowLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0'
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>{t('Go to previous page')}</span>
|
||||
<ChevronLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
{/* Page number buttons */}
|
||||
{pageNumbers.map((pageNumber, index) => (
|
||||
<div key={`${pageNumber}-${index}`} className='flex items-center'>
|
||||
{pageNumber === '...' ? (
|
||||
<span className='text-muted-foreground px-1 text-sm'>...</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === pageNumber ? 'default' : 'outline'}
|
||||
className='h-8 min-w-8 px-2'
|
||||
onClick={() => table.setPageIndex((pageNumber as number) - 1)}
|
||||
>
|
||||
<span className='sr-only'>Go to page {pageNumber}</span>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0'
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>{t('Go to next page')}</span>
|
||||
<ChevronRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0 @max-md/content:hidden'
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>{t('Go to last page')}</span>
|
||||
<DoubleArrowRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { TableRow, TableCell } from '@/components/ui/table'
|
||||
|
||||
interface TableEmptyProps {
|
||||
/**
|
||||
* Number of columns to span
|
||||
*/
|
||||
colSpan: number
|
||||
/**
|
||||
* Custom title for empty state
|
||||
* @default 'No Data'
|
||||
*/
|
||||
title?: string
|
||||
/**
|
||||
* Custom description for empty state
|
||||
* @default 'No records found. Try adjusting your filters.'
|
||||
*/
|
||||
description?: string
|
||||
/**
|
||||
* Custom icon component
|
||||
* @default Database icon
|
||||
*/
|
||||
icon?: React.ReactNode
|
||||
/**
|
||||
* Additional content to display (e.g., buttons)
|
||||
*/
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic table empty state component
|
||||
* Displays a centered empty state message when table has no data
|
||||
*/
|
||||
export function TableEmpty({
|
||||
colSpan,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
}: TableEmptyProps) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedTitle = title ?? t('No Data')
|
||||
const resolvedDescription =
|
||||
description ?? t('No records found. Try adjusting your filters.')
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} className='h-[400px] p-0'>
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
{icon || <Database className='size-6' />}
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedDescription}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{children}
|
||||
</Empty>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TableRow, TableCell } from '@/components/ui/table'
|
||||
|
||||
const SKELETON_WIDTHS = [
|
||||
'75%',
|
||||
'60%',
|
||||
'85%',
|
||||
'50%',
|
||||
'70%',
|
||||
'90%',
|
||||
'55%',
|
||||
'80%',
|
||||
'65%',
|
||||
'45%',
|
||||
]
|
||||
|
||||
interface TableSkeletonProps<TData> {
|
||||
table: Table<TData>
|
||||
rowCount?: number
|
||||
rowHeight?: string
|
||||
keyPrefix?: string
|
||||
}
|
||||
|
||||
export function TableSkeleton<TData>({
|
||||
table,
|
||||
rowCount,
|
||||
rowHeight = 'h-[52px]',
|
||||
keyPrefix = 'skeleton',
|
||||
}: TableSkeletonProps<TData>) {
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
|
||||
const finalRowCount =
|
||||
rowCount ?? Math.min(table.getState().pagination?.pageSize || 20, 20)
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: finalRowCount }, (_, rowIndex) => (
|
||||
<TableRow
|
||||
key={`${keyPrefix}-${rowIndex}`}
|
||||
className={cn(rowHeight, 'border-b')}
|
||||
>
|
||||
{visibleColumns.map((column, colIndex) => {
|
||||
const isSelectColumn = column.id === 'select'
|
||||
const widthIndex =
|
||||
(rowIndex * visibleColumns.length + colIndex) %
|
||||
SKELETON_WIDTHS.length
|
||||
|
||||
return (
|
||||
<TableCell key={column.id} className='py-3'>
|
||||
<Skeleton
|
||||
className={cn(
|
||||
'h-4 rounded-sm',
|
||||
isSelectColumn ? 'size-4' : undefined
|
||||
)}
|
||||
style={
|
||||
isSelectColumn
|
||||
? undefined
|
||||
: { width: SKELETON_WIDTHS[widthIndex] }
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import { useState } from 'react'
|
||||
import { Cross2Icon } from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { DataTableFacetedFilter } from './faceted-filter'
|
||||
import { DataTableViewOptions } from './view-options'
|
||||
|
||||
type DataTableToolbarProps<TData> = {
|
||||
table: Table<TData>
|
||||
searchPlaceholder?: string
|
||||
searchKey?: string
|
||||
filters?: {
|
||||
columnId: string
|
||||
title: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
singleSelect?: boolean
|
||||
}[]
|
||||
/** Custom search component to replace the default input */
|
||||
customSearch?: React.ReactNode
|
||||
/** Additional search input to show alongside the main search */
|
||||
additionalSearch?: React.ReactNode
|
||||
/** Whether additional filters are active (for showing reset button) */
|
||||
hasAdditionalFilters?: boolean
|
||||
/** Callback when reset button is clicked (for clearing additional filters) */
|
||||
onReset?: () => void
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
searchPlaceholder,
|
||||
searchKey,
|
||||
filters = [],
|
||||
customSearch,
|
||||
additionalSearch,
|
||||
hasAdditionalFilters = false,
|
||||
onReset,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
|
||||
const resolvedSearchPlaceholder = searchPlaceholder ?? t('Filter...')
|
||||
const isFiltered =
|
||||
table.getState().columnFilters.length > 0 ||
|
||||
table.getState().globalFilter ||
|
||||
hasAdditionalFilters
|
||||
|
||||
const activeFilterCount =
|
||||
table.getState().columnFilters.length + (hasAdditionalFilters ? 1 : 0)
|
||||
const hasFilterContent = filters.length > 0 || additionalSearch != null
|
||||
|
||||
const searchInput = searchKey ? (
|
||||
<Input
|
||||
placeholder={resolvedSearchPlaceholder}
|
||||
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ''}
|
||||
onChange={(event) =>
|
||||
table.getColumn(searchKey)?.setFilterValue(event.target.value)
|
||||
}
|
||||
className='h-8 w-full sm:w-[150px] lg:w-[250px]'
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={resolvedSearchPlaceholder}
|
||||
value={table.getState().globalFilter ?? ''}
|
||||
onChange={(event) => table.setGlobalFilter(event.target.value)}
|
||||
className='h-8 w-full sm:w-[150px] lg:w-[250px]'
|
||||
/>
|
||||
)
|
||||
|
||||
const filterChips = filters.map((filter) => {
|
||||
const column = table.getColumn(filter.columnId)
|
||||
if (!column) return null
|
||||
return (
|
||||
<DataTableFacetedFilter
|
||||
key={filter.columnId}
|
||||
column={column}
|
||||
title={filter.title}
|
||||
options={filter.options}
|
||||
singleSelect={filter.singleSelect}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const resetButton = isFiltered ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
table.resetColumnFilters()
|
||||
table.setGlobalFilter('')
|
||||
onReset?.()
|
||||
}}
|
||||
className='h-8 px-2 lg:px-3'
|
||||
>
|
||||
{t('Reset')}
|
||||
<Cross2Icon className='ms-2 h-4 w-4' />
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Search input */}
|
||||
{customSearch !== undefined ? customSearch : searchInput}
|
||||
|
||||
{/* Desktop: filters inline */}
|
||||
{additionalSearch && (
|
||||
<div className='hidden w-auto sm:block'>{additionalSearch}</div>
|
||||
)}
|
||||
<div className='hidden flex-wrap gap-2 sm:flex'>{filterChips}</div>
|
||||
<div className='hidden sm:block'>{resetButton}</div>
|
||||
|
||||
{/* Mobile: filter toggle button */}
|
||||
{hasFilterContent && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='relative h-8 shrink-0 gap-1 sm:hidden'
|
||||
onClick={() => setMobileFiltersOpen((v) => !v)}
|
||||
>
|
||||
<SlidersHorizontal className='h-3.5 w-3.5' />
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-4 min-w-4 rounded-full px-1 text-[10px] leading-none'
|
||||
>
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
|
||||
{/* Mobile: collapsible filter area */}
|
||||
{hasFilterContent && mobileFiltersOpen && (
|
||||
<div className='flex flex-wrap items-center gap-2 sm:hidden'>
|
||||
{additionalSearch && <div className='w-full'>{additionalSearch}</div>}
|
||||
{filterChips}
|
||||
{resetButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
|
||||
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
type DataTableViewOptionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableViewOptions<TData>({
|
||||
table,
|
||||
}: DataTableViewOptionsProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='ms-auto hidden h-8 lg:flex'
|
||||
>
|
||||
<MixerHorizontalIcon className='size-4' />
|
||||
{t('View')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[150px]'>
|
||||
<DropdownMenuLabel>{t('Toggle columns')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== 'undefined' && column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className='capitalize'
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.columnDef.meta?.label ?? column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { Calendar as CalendarIcon } from 'lucide-react'
|
||||
import { enUS, fr, ja, ru, vi, zhCN } from 'react-day-picker/locale'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
const calendarLocales = {
|
||||
en: enUS,
|
||||
zh: zhCN,
|
||||
fr,
|
||||
ru,
|
||||
ja,
|
||||
vi,
|
||||
} as const
|
||||
|
||||
type DatePickerProps = {
|
||||
selected: Date | undefined
|
||||
onSelect: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
selected,
|
||||
onSelect,
|
||||
placeholder,
|
||||
}: DatePickerProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const placeholderText = placeholder ?? t('Pick a date')
|
||||
const calendarLocale =
|
||||
calendarLocales[i18n.language as keyof typeof calendarLocales] ?? enUS
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
data-empty={!selected}
|
||||
className='data-[empty=true]:text-muted-foreground w-[240px] justify-start text-start font-normal'
|
||||
>
|
||||
{selected ? (
|
||||
dayjs(selected).format('YYYY-MM-DD')
|
||||
) : (
|
||||
<span>{placeholderText}</span>
|
||||
)}
|
||||
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-0'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
captionLayout='dropdown'
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
locale={calendarLocale}
|
||||
disabled={(date: Date) =>
|
||||
date > new Date() || date < new Date('1900-01-01')
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import * as React from 'react'
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
import { enUS, fr, ja, ru, vi, zhCN } from 'react-day-picker/locale'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
const calendarLocales = {
|
||||
en: enUS,
|
||||
zh: zhCN,
|
||||
fr,
|
||||
ru,
|
||||
ja,
|
||||
vi,
|
||||
} as const
|
||||
|
||||
interface DateTimePickerProps {
|
||||
value?: Date
|
||||
onChange?: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DateTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
}: DateTimePickerProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const placeholderText = placeholder ?? t('Select date')
|
||||
const calendarLocale =
|
||||
calendarLocales[i18n.language as keyof typeof calendarLocales] ?? enUS
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [date, setDate] = React.useState<Date | undefined>(value)
|
||||
const [month, setMonth] = React.useState<Date | undefined>(value)
|
||||
const [time, setTime] = React.useState<string>('00:00')
|
||||
|
||||
React.useEffect(() => {
|
||||
setDate(value)
|
||||
setMonth(value)
|
||||
if (value) {
|
||||
const hours = value.getHours().toString().padStart(2, '0')
|
||||
const minutes = value.getMinutes().toString().padStart(2, '0')
|
||||
setTime(`${hours}:${minutes}`)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const handleDateSelect = (selectedDate: Date | undefined) => {
|
||||
if (selectedDate) {
|
||||
const [hours, minutes] = time.split(':').map(Number)
|
||||
const newDate = new Date(selectedDate)
|
||||
newDate.setHours(hours, minutes, 0, 0)
|
||||
setDate(newDate)
|
||||
setMonth(newDate)
|
||||
onChange?.(newDate)
|
||||
setOpen(false)
|
||||
} else {
|
||||
setDate(undefined)
|
||||
setMonth(undefined)
|
||||
onChange?.(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = e.target.value
|
||||
setTime(newTime)
|
||||
|
||||
if (date) {
|
||||
const [hours, minutes] = newTime.split(':').map(Number)
|
||||
const newDate = new Date(date)
|
||||
newDate.setHours(hours, minutes, 0, 0)
|
||||
setDate(newDate)
|
||||
onChange?.(newDate)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setDate(undefined)
|
||||
setMonth(undefined)
|
||||
setTime('00:00')
|
||||
onChange?.(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-2', className)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'flex-1 justify-between font-normal',
|
||||
!date && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{date ? dayjs(date).format('YYYY-MM-DD') : placeholderText}
|
||||
<ChevronDownIcon className='h-4 w-4 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto overflow-hidden p-0' align='start'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
selected={date}
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
captionLayout='dropdown'
|
||||
onSelect={handleDateSelect}
|
||||
locale={calendarLocale}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Input
|
||||
type='time'
|
||||
value={time}
|
||||
onChange={handleTimeChange}
|
||||
className='w-32 appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none'
|
||||
disabled={!date}
|
||||
/>
|
||||
{date && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleClear}
|
||||
className='shrink-0'
|
||||
aria-label='Clear'
|
||||
>
|
||||
<span aria-hidden='true'>✕</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Database, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { FadeIn } from '@/components/page-transition'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon
|
||||
title?: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
bordered?: boolean
|
||||
}
|
||||
|
||||
export function EmptyState(props: EmptyStateProps) {
|
||||
const { t } = useTranslation()
|
||||
const Icon = props.icon ?? Database
|
||||
|
||||
return (
|
||||
<FadeIn>
|
||||
<Empty
|
||||
className={cn(
|
||||
'min-h-[300px]',
|
||||
props.bordered && 'border',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Icon className='size-6' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{props.title ?? t('No Data')}</EmptyTitle>
|
||||
{props.description != null && (
|
||||
<EmptyDescription>{props.description}</EmptyDescription>
|
||||
)}
|
||||
</EmptyHeader>
|
||||
{props.action != null && <EmptyContent>{props.action}</EmptyContent>}
|
||||
</Empty>
|
||||
</FadeIn>
|
||||
)
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangle, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { FadeIn } from '@/components/page-transition'
|
||||
|
||||
interface ErrorStateProps {
|
||||
icon?: LucideIcon
|
||||
title?: string
|
||||
description?: string
|
||||
onRetry?: () => void
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ErrorState(props: ErrorStateProps) {
|
||||
const { t } = useTranslation()
|
||||
const Icon = props.icon ?? AlertTriangle
|
||||
|
||||
return (
|
||||
<FadeIn>
|
||||
<Empty className={cn('min-h-[300px]', props.className)}>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Icon className='text-destructive size-6' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>
|
||||
{props.title ?? t('Oops! Something went wrong')}
|
||||
</EmptyTitle>
|
||||
{props.description != null && (
|
||||
<EmptyDescription>{props.description}</EmptyDescription>
|
||||
)}
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
{props.onRetry != null && (
|
||||
<Button variant='outline' size='sm' onClick={props.onRetry}>
|
||||
{t('Retry')}
|
||||
</Button>
|
||||
)}
|
||||
{props.action}
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</FadeIn>
|
||||
)
|
||||
}
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Code, Table, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
type JsonEditorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
keyPlaceholder?: string
|
||||
valuePlaceholder?: string
|
||||
keyLabel?: string
|
||||
valueLabel?: string
|
||||
emptyMessage?: string
|
||||
template?: Record<string, unknown>
|
||||
valueType?: 'string' | 'number' | 'any'
|
||||
}
|
||||
|
||||
type EditorRow = {
|
||||
id: string
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function JsonEditor({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
keyPlaceholder,
|
||||
valuePlaceholder,
|
||||
keyLabel,
|
||||
valueLabel,
|
||||
emptyMessage,
|
||||
template,
|
||||
valueType = 'string',
|
||||
}: JsonEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedEmptyMessage =
|
||||
emptyMessage ?? t('No mappings configured. Click "Add Row" to get started.')
|
||||
const resolvedKeyPlaceholder = keyPlaceholder ?? t('Key')
|
||||
const resolvedValuePlaceholder = valuePlaceholder ?? t('Value')
|
||||
const resolvedKeyLabel = keyLabel ?? t('Key')
|
||||
const resolvedValueLabel = valueLabel ?? t('Value')
|
||||
const [mode, setMode] = useState<'visual' | 'json'>('visual')
|
||||
const [rows, setRows] = useState<EditorRow[]>([])
|
||||
const [jsonValue, setJsonValue] = useState(value)
|
||||
|
||||
const parseJsonToRows = (json: string) => {
|
||||
try {
|
||||
if (!json.trim()) {
|
||||
setRows([])
|
||||
return
|
||||
}
|
||||
const parsed = JSON.parse(json)
|
||||
const newRows: EditorRow[] = Object.entries(parsed).map(
|
||||
([key, val], index) => ({
|
||||
id: `${Date.now()}-${index}`,
|
||||
key,
|
||||
value: typeof val === 'object' ? JSON.stringify(val) : String(val),
|
||||
})
|
||||
)
|
||||
setRows(newRows)
|
||||
} catch (_error) {
|
||||
// Invalid JSON, keep current rows
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON to rows when value changes externally
|
||||
useEffect(() => {
|
||||
if (value !== jsonValue) {
|
||||
setJsonValue(value)
|
||||
parseJsonToRows(value)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value])
|
||||
|
||||
const convertRowsToJson = (updatedRows: EditorRow[]): string => {
|
||||
if (updatedRows.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const obj: Record<string, unknown> = {}
|
||||
updatedRows.forEach((row) => {
|
||||
if (row.key.trim()) {
|
||||
let parsedValue: unknown = row.value.trim()
|
||||
|
||||
// Try to parse value based on type
|
||||
if (valueType === 'number') {
|
||||
parsedValue = Number(parsedValue) || 0
|
||||
} else if (valueType === 'any') {
|
||||
// Try to parse as JSON first
|
||||
try {
|
||||
parsedValue = JSON.parse(row.value)
|
||||
} catch {
|
||||
// If not valid JSON, keep as string
|
||||
parsedValue = row.value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
obj[row.key.trim()] = parsedValue
|
||||
}
|
||||
})
|
||||
return JSON.stringify(obj, null, 2)
|
||||
}
|
||||
|
||||
const handleAddRow = () => {
|
||||
const newRow: EditorRow = {
|
||||
id: `${Date.now()}`,
|
||||
key: '',
|
||||
value: '',
|
||||
}
|
||||
const updatedRows = [...rows, newRow]
|
||||
setRows(updatedRows)
|
||||
}
|
||||
|
||||
const handleDeleteRow = (id: string) => {
|
||||
const updatedRows = rows.filter((row) => row.id !== id)
|
||||
setRows(updatedRows)
|
||||
const json = convertRowsToJson(updatedRows)
|
||||
setJsonValue(json)
|
||||
onChange(json)
|
||||
}
|
||||
|
||||
const handleRowChange = (
|
||||
id: string,
|
||||
field: 'key' | 'value',
|
||||
newValue: string
|
||||
) => {
|
||||
const updatedRows = rows.map((row) =>
|
||||
row.id === id ? { ...row, [field]: newValue } : row
|
||||
)
|
||||
setRows(updatedRows)
|
||||
const json = convertRowsToJson(updatedRows)
|
||||
setJsonValue(json)
|
||||
onChange(json)
|
||||
}
|
||||
|
||||
const handleJsonChange = (newJson: string) => {
|
||||
setJsonValue(newJson)
|
||||
onChange(newJson)
|
||||
parseJsonToRows(newJson)
|
||||
}
|
||||
|
||||
const handleFillTemplate = () => {
|
||||
if (!template) return
|
||||
const templateJson = JSON.stringify(template, null, 2)
|
||||
setJsonValue(templateJson)
|
||||
onChange(templateJson)
|
||||
parseJsonToRows(templateJson)
|
||||
}
|
||||
|
||||
const toggleMode = () => {
|
||||
if (mode === 'visual') {
|
||||
// Switching to JSON mode: sync rows to JSON
|
||||
const json = convertRowsToJson(rows)
|
||||
setJsonValue(json)
|
||||
onChange(json)
|
||||
setMode('json')
|
||||
} else {
|
||||
// Switching to visual mode: sync JSON to rows
|
||||
parseJsonToRows(jsonValue)
|
||||
setMode('visual')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={toggleMode}
|
||||
disabled={disabled}
|
||||
>
|
||||
{mode === 'visual' ? (
|
||||
<>
|
||||
<Code className='mr-2 h-4 w-4' />
|
||||
{t('JSON Mode')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Table className='mr-2 h-4 w-4' />
|
||||
{t('Visual Mode')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{template && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='h-auto p-0'
|
||||
onClick={handleFillTemplate}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('Fill Template')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'visual' ? (
|
||||
<div className='space-y-2'>
|
||||
{rows.length > 0 ? (
|
||||
<div className='space-y-2'>
|
||||
<div className='grid grid-cols-[1fr_1fr_auto] gap-2 text-sm font-medium'>
|
||||
<div>{resolvedKeyLabel}</div>
|
||||
<div>{resolvedValueLabel}</div>
|
||||
<div className='w-10'></div>
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className='grid grid-cols-[1fr_1fr_auto] gap-2'
|
||||
>
|
||||
<Input
|
||||
value={row.key}
|
||||
onChange={(e) =>
|
||||
handleRowChange(row.id, 'key', e.target.value)
|
||||
}
|
||||
placeholder={resolvedKeyPlaceholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
onChange={(e) =>
|
||||
handleRowChange(row.id, 'value', e.target.value)
|
||||
}
|
||||
placeholder={resolvedValuePlaceholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
aria-label='Delete row'
|
||||
onClick={() => handleDeleteRow(row.id)}
|
||||
disabled={disabled}
|
||||
className='h-10 w-10'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' aria-hidden='true' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted-foreground flex h-24 items-center justify-center rounded-md border border-dashed text-sm'>
|
||||
{resolvedEmptyMessage}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleAddRow}
|
||||
disabled={disabled}
|
||||
className='w-full'
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add Row')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={jsonValue}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
placeholder={
|
||||
template ? JSON.stringify(template, null, 2) : '{"key": "value"}'
|
||||
}
|
||||
disabled={disabled}
|
||||
rows={8}
|
||||
className={cn('font-mono text-sm')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback } from 'react'
|
||||
import { Languages, Check } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'zh', label: '中文' },
|
||||
{ code: 'fr', label: 'Français' },
|
||||
{ code: 'ru', label: 'Русский' },
|
||||
{ code: 'ja', label: '日本語' },
|
||||
{ code: 'vi', label: 'Tiếng Việt' },
|
||||
]
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const user = useAuthStore((s) => s.auth.user)
|
||||
|
||||
const handleChangeLanguage = useCallback(
|
||||
async (code: string) => {
|
||||
await i18n.changeLanguage(code)
|
||||
if (user) {
|
||||
try {
|
||||
await api.put('/api/user/self', { language: code })
|
||||
} catch {
|
||||
// Best-effort persistence; don't block the UI on failure
|
||||
}
|
||||
}
|
||||
},
|
||||
[i18n, user]
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' size='icon' className='h-9 w-9 rounded-full'>
|
||||
<Languages className='size-[1.2rem]' />
|
||||
<span className='sr-only'>{t('Change language')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
{languages.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang.code}
|
||||
onClick={() => handleChangeLanguage(lang.code)}
|
||||
>
|
||||
{lang.label}
|
||||
<Check
|
||||
size={14}
|
||||
className={cn('ms-auto', i18n.language !== lang.code && 'hidden')}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useNotifications } from '@/hooks/use-notifications'
|
||||
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
|
||||
import { ConfigDrawer } from '@/components/config-drawer'
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { NotificationButton } from '@/components/notification-button'
|
||||
import { NotificationDialog } from '@/components/notification-dialog'
|
||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||
import { Search } from '@/components/search'
|
||||
import { defaultTopNavLinks } from '../config/top-nav.config'
|
||||
import { type TopNavLink } from '../types'
|
||||
import { Header } from './header'
|
||||
import { TopNav } from './top-nav'
|
||||
|
||||
/**
|
||||
* General application Header component
|
||||
* Integrates navigation bar, search, configuration and profile functions
|
||||
*
|
||||
* @example
|
||||
* // Basic usage
|
||||
* <AppHeader />
|
||||
*
|
||||
* @example
|
||||
* // Custom navigation links
|
||||
* <AppHeader navLinks={customLinks} />
|
||||
*
|
||||
* @example
|
||||
* // Hide navigation bar and search box
|
||||
* <AppHeader showTopNav={false} showSearch={false} />
|
||||
*
|
||||
* @example
|
||||
* // Fully customize left and right content
|
||||
* <AppHeader
|
||||
* leftContent={<CustomLeft />}
|
||||
* rightContent={<CustomRight />}
|
||||
* />
|
||||
*/
|
||||
type AppHeaderProps = {
|
||||
/**
|
||||
* Custom navigation links, uses default global navigation or dynamically generated from backend if not provided
|
||||
*/
|
||||
navLinks?: TopNavLink[]
|
||||
/**
|
||||
* Whether to show top navigation bar
|
||||
* @default true
|
||||
*/
|
||||
showTopNav?: boolean
|
||||
/**
|
||||
* Left content, overrides TopNav if provided
|
||||
*/
|
||||
leftContent?: React.ReactNode
|
||||
/**
|
||||
* Whether to show search box
|
||||
* @default true
|
||||
*/
|
||||
showSearch?: boolean
|
||||
/**
|
||||
* Custom right content, overrides default right content if provided
|
||||
*/
|
||||
rightContent?: React.ReactNode
|
||||
/**
|
||||
* Whether to show notification button
|
||||
* @default true
|
||||
*/
|
||||
showNotifications?: boolean
|
||||
/**
|
||||
* Whether to show config drawer
|
||||
* @default true
|
||||
*/
|
||||
showConfigDrawer?: boolean
|
||||
/**
|
||||
* Whether to show profile dropdown
|
||||
* @default true
|
||||
*/
|
||||
showProfileDropdown?: boolean
|
||||
}
|
||||
|
||||
export function AppHeader({
|
||||
navLinks = defaultTopNavLinks,
|
||||
showTopNav = true,
|
||||
leftContent,
|
||||
showSearch = true,
|
||||
rightContent,
|
||||
showNotifications = true,
|
||||
showConfigDrawer = true,
|
||||
showProfileDropdown = true,
|
||||
}: AppHeaderProps) {
|
||||
// Prioritize dynamically generated links from backend
|
||||
const dynamicLinks = useTopNavLinks()
|
||||
const links = dynamicLinks.length > 0 ? dynamicLinks : navLinks
|
||||
|
||||
// Notifications hook
|
||||
const notifications = useNotifications()
|
||||
|
||||
// Determine left content: custom content > navigation bar > null
|
||||
const leftSection =
|
||||
leftContent || (showTopNav ? <TopNav links={links} /> : null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
{leftSection}
|
||||
{rightContent ?? (
|
||||
<div className='ms-auto flex items-center space-x-4'>
|
||||
{showSearch && <Search />}
|
||||
{showNotifications && (
|
||||
<NotificationButton
|
||||
unreadCount={notifications.unreadCount}
|
||||
onClick={() => notifications.openDialog()}
|
||||
/>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
{showConfigDrawer && <ConfigDrawer />}
|
||||
{showProfileDropdown && <ProfileDropdown />}
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
{/* Notification Dialog */}
|
||||
{showNotifications && (
|
||||
<NotificationDialog
|
||||
open={notifications.dialogOpen}
|
||||
onOpenChange={notifications.setDialogOpen}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
onCloseToday={notifications.closeToday}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useLocation } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { useLayout } from '@/context/layout-provider'
|
||||
import { useSidebarConfig } from '@/hooks/use-sidebar-config'
|
||||
import { useSidebarData } from '@/hooks/use-sidebar-data'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { getNavGroupsForPath } from '../lib/workspace-registry'
|
||||
import { NavGroup } from './nav-group'
|
||||
import { WorkspaceSwitcher } from './workspace-switcher'
|
||||
|
||||
/**
|
||||
* Application sidebar component
|
||||
* Fetches corresponding navigation menu from workspace registry based on current path
|
||||
* Dynamically filters navigation items based on backend SidebarModulesAdmin configuration
|
||||
*
|
||||
* Automatically matches workspace configuration for current path through workspace registry system
|
||||
* Adding new workspaces only requires registration in workspace-registry.ts
|
||||
*/
|
||||
export function AppSidebar() {
|
||||
const { t } = useTranslation()
|
||||
const { collapsible, variant } = useLayout()
|
||||
const { pathname } = useLocation()
|
||||
const userRole = useAuthStore((state) => state.auth.user?.role)
|
||||
const sidebarData = useSidebarData()
|
||||
|
||||
// Get navigation group configuration corresponding to current path from workspace registry
|
||||
const allNavGroups = getNavGroupsForPath(pathname, t) || sidebarData.navGroups
|
||||
|
||||
// Filter sidebar navigation items based on backend configuration
|
||||
const configFilteredNavGroups = useSidebarConfig(allNavGroups)
|
||||
|
||||
// Filter navigation groups based on user role
|
||||
// Non-Admin users cannot see Admin navigation group
|
||||
const currentNavGroups = useMemo(() => {
|
||||
const isAdmin = userRole && userRole >= ROLE.ADMIN
|
||||
return configFilteredNavGroups.filter((group) => {
|
||||
if (group.id === 'admin') {
|
||||
return isAdmin
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [configFilteredNavGroups, userRole])
|
||||
|
||||
return (
|
||||
<Sidebar collapsible={collapsible} variant={variant}>
|
||||
<SidebarHeader>
|
||||
<WorkspaceSwitcher workspaces={sidebarData.workspaces} />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{currentNavGroups.map((props) => {
|
||||
const key = props.id || props.title
|
||||
return <NavGroup key={key} {...props} />
|
||||
})}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getCookie } from '@/lib/cookies'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LayoutProvider } from '@/context/layout-provider'
|
||||
import { SearchProvider } from '@/context/search-provider'
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { AnimatedOutlet } from '@/components/page-transition'
|
||||
import { SkipToMain } from '@/components/skip-to-main'
|
||||
import { WorkspaceProvider } from '../context/workspace-context'
|
||||
import { AppSidebar } from './app-sidebar'
|
||||
|
||||
type AuthenticatedLayoutProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
|
||||
const defaultOpen = getCookie('sidebar_state') !== 'false'
|
||||
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<SearchProvider>
|
||||
<WorkspaceProvider>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<SkipToMain />
|
||||
<AppSidebar />
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
'@container/content',
|
||||
'h-svh',
|
||||
'peer-data-[variant=inset]:h-[calc(100svh-(var(--spacing)*4))]'
|
||||
)}
|
||||
>
|
||||
{props.children ?? <AnimatedOutlet />}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</WorkspaceProvider>
|
||||
</SearchProvider>
|
||||
</LayoutProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import { Link, useLocation } from '@tanstack/react-router'
|
||||
import { ExternalLink, Loader2, ChevronRight } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useActiveChatKey } from '@/features/chat/hooks/use-active-chat-key'
|
||||
import { useChatPresets } from '@/features/chat/hooks/use-chat-presets'
|
||||
import { resolveChatUrl, type ChatPreset } from '@/features/chat/lib/chat-links'
|
||||
import { normalizeHref } from '../lib/url-utils'
|
||||
import type { NavChatPresets } from '../types'
|
||||
|
||||
/**
|
||||
* Check if a preset requires an API key
|
||||
*/
|
||||
function requiresApiKey(preset: ChatPreset): boolean {
|
||||
return preset.url.includes('{key}') || preset.url.includes('{cherryConfig}')
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-menu item for a single chat preset
|
||||
*/
|
||||
function ChatMenuItem({
|
||||
preset,
|
||||
active,
|
||||
onOpen,
|
||||
onNavigate,
|
||||
}: {
|
||||
preset: ChatPreset
|
||||
active: boolean
|
||||
onOpen: (preset: ChatPreset) => void
|
||||
onNavigate: () => void
|
||||
}) {
|
||||
if (preset.type === 'web') {
|
||||
return (
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton asChild isActive={active}>
|
||||
<Link
|
||||
to='/chat/$chatId'
|
||||
params={{ chatId: preset.id }}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<span>{preset.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
onClick={() => onOpen(preset)}
|
||||
isActive={false}
|
||||
className='justify-between'
|
||||
>
|
||||
<span>{preset.name}</span>
|
||||
<ExternalLink className='h-4 w-4' />
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown menu item for a single chat preset
|
||||
*/
|
||||
function DropdownPresetItem({
|
||||
preset,
|
||||
onOpen,
|
||||
}: {
|
||||
preset: ChatPreset
|
||||
onOpen: (preset: ChatPreset) => void
|
||||
}) {
|
||||
if (preset.type === 'web') {
|
||||
return (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/chat/$chatId' params={{ chatId: preset.id }}>
|
||||
{preset.name}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem onClick={() => onOpen(preset)}>
|
||||
{preset.name}
|
||||
<ExternalLink className='ml-auto h-4 w-4 opacity-70' />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic chat presets navigation item
|
||||
*/
|
||||
export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
|
||||
const { t } = useTranslation()
|
||||
const { chatPresets, serverAddress } = useChatPresets()
|
||||
const { state, isMobile, setOpenMobile } = useSidebar()
|
||||
const href = useLocation({ select: (location) => location.href })
|
||||
const loadingMessage = t('Preparing chat keys…')
|
||||
|
||||
const visiblePresets = useMemo(
|
||||
() => chatPresets.filter((preset) => preset.type !== 'fluent'),
|
||||
[chatPresets]
|
||||
)
|
||||
|
||||
const hasKeyDependentPresets = useMemo(
|
||||
() => visiblePresets.some(requiresApiKey),
|
||||
[visiblePresets]
|
||||
)
|
||||
|
||||
const {
|
||||
data: activeKey,
|
||||
isPending: isKeyPending,
|
||||
error: keyError,
|
||||
} = useActiveChatKey(hasKeyDependentPresets)
|
||||
|
||||
const handleOpenExternal = useCallback(
|
||||
(preset: ChatPreset) => {
|
||||
if (preset.type === 'web') return
|
||||
|
||||
const needsKey = requiresApiKey(preset)
|
||||
|
||||
if (needsKey && isKeyPending) {
|
||||
toast.info(t('Preparing your chat link, please try again in a moment.'))
|
||||
return
|
||||
}
|
||||
|
||||
if (needsKey && !activeKey) {
|
||||
const message =
|
||||
keyError instanceof Error
|
||||
? keyError.message
|
||||
: t(
|
||||
'Unable to prepare chat link. Please ensure you have an enabled API key.'
|
||||
)
|
||||
toast.error(message)
|
||||
return
|
||||
}
|
||||
|
||||
const url = resolveChatUrl({
|
||||
template: preset.url,
|
||||
apiKey: needsKey ? activeKey : undefined,
|
||||
serverAddress,
|
||||
})
|
||||
|
||||
if (!url) {
|
||||
toast.error(t('Invalid chat link. Please contact the administrator.'))
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
window.open(url, '_blank', 'noopener')
|
||||
setOpenMobile(false)
|
||||
},
|
||||
[activeKey, isKeyPending, keyError, serverAddress, setOpenMobile, t]
|
||||
)
|
||||
|
||||
const normalizedHref = normalizeHref(href)
|
||||
|
||||
// Don't render if no visible presets
|
||||
if (visiblePresets.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Collapsed state on non-mobile - render dropdown menu
|
||||
if (state === 'collapsed' && !isMobile) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title}>
|
||||
{item.icon && <item.icon className='h-4 w-4' />}
|
||||
<span>{item.title}</span>
|
||||
<ChevronRight className='ms-auto h-4 w-4 opacity-70' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
{visiblePresets.map((preset) => (
|
||||
<DropdownPresetItem
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
onOpen={handleOpenExternal}
|
||||
/>
|
||||
))}
|
||||
{hasKeyDependentPresets && <DropdownMenuSeparator />}
|
||||
{hasKeyDependentPresets && isKeyPending && (
|
||||
<DropdownMenuItem disabled>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
{loadingMessage}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
// Expanded state - render collapsible menu
|
||||
return (
|
||||
<Collapsible
|
||||
asChild
|
||||
defaultOpen={normalizedHref.startsWith('/chat')}
|
||||
className='group/collapsible'
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='CollapsibleContent'>
|
||||
<SidebarMenuSub>
|
||||
{visiblePresets.map((preset) => (
|
||||
<ChatMenuItem
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
active={normalizedHref === `/chat/${preset.id}`}
|
||||
onOpen={handleOpenExternal}
|
||||
onNavigate={() => setOpenMobile(false)}
|
||||
/>
|
||||
))}
|
||||
{hasKeyDependentPresets && isKeyPending && (
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton aria-disabled='true' tabIndex={-1}>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
{loadingMessage}
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
)}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
|
||||
interface FooterLink {
|
||||
text: string
|
||||
href: string
|
||||
}
|
||||
|
||||
interface FooterColumnProps {
|
||||
title: string
|
||||
links: FooterLink[]
|
||||
}
|
||||
|
||||
interface FooterProps {
|
||||
logo?: string
|
||||
name?: string
|
||||
columns?: FooterColumnProps[]
|
||||
copyright?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function FooterLinkItem(props: { link: FooterLink }) {
|
||||
const { t } = useTranslation()
|
||||
const isExternal = props.link.href.startsWith('http')
|
||||
const label = t(props.link.text)
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
href={props.link.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-muted-foreground hover:text-foreground text-sm transition-colors duration-200'
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={props.link.href}
|
||||
className='text-muted-foreground hover:text-foreground text-sm transition-colors duration-200'
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Footer(props: FooterProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
systemName,
|
||||
logo: systemLogo,
|
||||
footerHtml,
|
||||
demoSiteEnabled,
|
||||
} = useSystemConfig()
|
||||
|
||||
const displayLogo = systemLogo || props.logo || '/logo.png'
|
||||
const displayName = systemName || props.name || 'New API'
|
||||
const isDemoSiteMode = Boolean(demoSiteEnabled)
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const fallbackColumns = useMemo<FooterColumnProps[]>(
|
||||
() => [
|
||||
{
|
||||
title: t('footer.columns.about.title'),
|
||||
links: [
|
||||
{
|
||||
text: t('footer.columns.about.links.aboutProject'),
|
||||
href: 'https://docs.newapi.pro/wiki/project-introduction/',
|
||||
},
|
||||
{
|
||||
text: t('footer.columns.about.links.contact'),
|
||||
href: 'https://docs.newapi.pro/support/community-interaction/',
|
||||
},
|
||||
{
|
||||
text: t('footer.columns.about.links.features'),
|
||||
href: 'https://docs.newapi.pro/wiki/features-introduction/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('footer.columns.docs.title'),
|
||||
links: [
|
||||
{
|
||||
text: t('footer.columns.docs.links.quickStart'),
|
||||
href: 'https://docs.newapi.pro/getting-started/',
|
||||
},
|
||||
{
|
||||
text: t('footer.columns.docs.links.installation'),
|
||||
href: 'https://docs.newapi.pro/installation/',
|
||||
},
|
||||
{
|
||||
text: t('footer.columns.docs.links.apiDocs'),
|
||||
href: 'https://docs.newapi.pro/api/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('footer.columns.related.title'),
|
||||
links: [
|
||||
{
|
||||
text: t('footer.columns.related.links.oneApi'),
|
||||
href: 'https://github.com/songquanpeng/one-api',
|
||||
},
|
||||
{
|
||||
text: t('footer.columns.related.links.midjourney'),
|
||||
href: 'https://github.com/novicezk/midjourney-proxy',
|
||||
},
|
||||
{
|
||||
text: t('footer.columns.related.links.neko'),
|
||||
href: 'https://github.com/Calcium-Ion/neko-api-key-tool',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const displayColumns = props.columns ?? fallbackColumns
|
||||
|
||||
if (footerHtml) {
|
||||
return (
|
||||
<div
|
||||
className='custom-footer w-full'
|
||||
dangerouslySetInnerHTML={{ __html: footerHtml }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={cn('border-border/40 relative z-10 border-t', props.className)}
|
||||
>
|
||||
<div className='mx-auto max-w-6xl px-6 py-12 md:py-16'>
|
||||
<div className='flex flex-col justify-between gap-10 md:flex-row md:gap-16'>
|
||||
{/* Brand column */}
|
||||
<div className='shrink-0'>
|
||||
<Link to='/' className='group flex items-center gap-2.5'>
|
||||
<img
|
||||
src={displayLogo}
|
||||
alt={displayName}
|
||||
className='size-7 rounded-lg object-contain'
|
||||
/>
|
||||
<span className='text-sm font-semibold tracking-tight'>
|
||||
{displayName}
|
||||
</span>
|
||||
</Link>
|
||||
<p className='text-muted-foreground/60 mt-3 max-w-[200px] text-xs leading-relaxed'>
|
||||
{t('Powerful API Management Platform')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Links columns */}
|
||||
{isDemoSiteMode && (
|
||||
<div className='grid grid-cols-3 gap-8 md:gap-16'>
|
||||
{displayColumns.map((column, index) => (
|
||||
<div key={index}>
|
||||
<p className='text-muted-foreground/50 mb-3 text-xs font-medium tracking-wider uppercase'>
|
||||
{t(column.title)}
|
||||
</p>
|
||||
<ul className='space-y-2.5'>
|
||||
{column.links.map((link, linkIndex) => (
|
||||
<li key={linkIndex}>
|
||||
<FooterLinkItem link={link} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className='border-border/30 mt-12 flex flex-col items-center justify-between gap-3 border-t pt-6 sm:flex-row'>
|
||||
<p className='text-muted-foreground/40 text-xs'>
|
||||
© {currentYear} {displayName}.{' '}
|
||||
{props.copyright ?? t('footer.defaultCopyright')}
|
||||
</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground/40 text-xs'>
|
||||
{t('Designed and Developed by')}{' '}
|
||||
</span>
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary text-xs font-medium hover:underline'
|
||||
>
|
||||
{t('New API')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const glowVariants = cva('absolute w-full', {
|
||||
variants: {
|
||||
variant: {
|
||||
top: 'top-0',
|
||||
above: '-top-[128px]',
|
||||
bottom: 'bottom-0',
|
||||
below: '-bottom-[128px]',
|
||||
center: 'top-[50%]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'top',
|
||||
},
|
||||
})
|
||||
|
||||
export interface GlowProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof glowVariants> {}
|
||||
|
||||
export function Glow({ className, variant, ...props }: GlowProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot='glow'
|
||||
className={cn(glowVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-1/2 h-[256px] w-[60%] -translate-x-1/2 scale-[2.5] rounded-[50%] bg-radial from-amber-500/60 from-10% to-amber-500/0 to-60% opacity-40 sm:h-[512px] dark:opacity-80',
|
||||
variant === 'center' && '-translate-y-1/2'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-1/2 h-[128px] w-[40%] -translate-x-1/2 scale-200 rounded-[50%] bg-radial from-yellow-400/50 from-10% to-yellow-400/0 to-60% opacity-30 sm:h-[256px] dark:opacity-70',
|
||||
variant === 'center' && '-translate-y-1/2'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface HeaderLogoProps {
|
||||
src: string
|
||||
alt?: string
|
||||
loading: boolean
|
||||
logoLoaded: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Logo component for header with loading state
|
||||
* Shows image only when fully loaded for smooth UX
|
||||
*/
|
||||
export function HeaderLogo({
|
||||
src,
|
||||
alt = 'logo',
|
||||
loading,
|
||||
logoLoaded,
|
||||
className,
|
||||
}: HeaderLogoProps) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
'h-6 w-6 rounded-full transition-opacity duration-200',
|
||||
!loading && logoLoaded ? 'opacity-100' : 'opacity-0',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
|
||||
type HeaderProps = React.HTMLAttributes<HTMLElement>
|
||||
|
||||
export function Header({ className, children, ...props }: HeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className={cn('bg-background z-50 h-16 shrink-0 border-b', className)}
|
||||
{...props}
|
||||
>
|
||||
<div className='flex h-full items-center gap-3 p-4 sm:gap-4'>
|
||||
<SidebarTrigger variant='outline' />
|
||||
<Separator orientation='vertical' className='h-6' />
|
||||
{children}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export interface LogoProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
image: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||||
name: string
|
||||
version?: string
|
||||
width?: number
|
||||
height?: number
|
||||
showName?: boolean
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export function Logo({
|
||||
className,
|
||||
image: SvgImage,
|
||||
name,
|
||||
version,
|
||||
width = 24,
|
||||
height = 24,
|
||||
showName = true,
|
||||
badge,
|
||||
...props
|
||||
}: LogoProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot='logo'
|
||||
className={cn('flex items-center gap-2 text-sm font-medium', className)}
|
||||
{...props}
|
||||
>
|
||||
<SvgImage
|
||||
width={width}
|
||||
height={height}
|
||||
aria-hidden='true'
|
||||
className='max-h-full max-w-full opacity-70'
|
||||
/>
|
||||
<span className={cn(!showName && 'sr-only')}>{name}</span>
|
||||
{version && <span className='text-muted-foreground'>{version}</span>}
|
||||
{badge && <Badge variant='secondary'>{badge}</Badge>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type MainProps = React.HTMLAttributes<HTMLElement> & {
|
||||
fluid?: boolean
|
||||
}
|
||||
|
||||
export function Main({ className, fluid = true, ...props }: MainProps) {
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-hidden',
|
||||
!fluid &&
|
||||
'@7xl/content:mx-auto @7xl/content:w-full @7xl/content:max-w-7xl',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { X, User, Wallet, LogOut } from 'lucide-react'
|
||||
import { AnimatePresence, motion, type Variants } from 'motion/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AuthUser } from '@/stores/auth-store'
|
||||
import useDialogState from '@/hooks/use-dialog'
|
||||
import { useUserDisplay } from '@/hooks/use-user-display'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { SignOutDialog } from '@/components/sign-out-dialog'
|
||||
import { MOBILE_DRAWER_ANIMATION, MOBILE_DRAWER_CONFIG } from '../constants'
|
||||
import type { TopNavLink } from '../types'
|
||||
|
||||
/**
|
||||
* Brand logo component with skeleton loading
|
||||
*/
|
||||
interface BrandLogoProps {
|
||||
homeUrl: string
|
||||
displayLogo: React.ReactNode
|
||||
displaySiteName: string
|
||||
loading: boolean
|
||||
logoLoaded: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
function BrandLogo({
|
||||
homeUrl,
|
||||
displayLogo,
|
||||
displaySiteName,
|
||||
loading,
|
||||
logoLoaded,
|
||||
onClick,
|
||||
}: BrandLogoProps) {
|
||||
return (
|
||||
<Link
|
||||
to={homeUrl}
|
||||
className='flex items-center gap-2 text-xl font-bold'
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className='relative h-6 w-6'>
|
||||
{loading || !logoLoaded ? (
|
||||
<Skeleton className='absolute inset-0 rounded-full' />
|
||||
) : null}
|
||||
{displayLogo}
|
||||
</div>
|
||||
{loading ? <Skeleton className='h-5 w-20' /> : displaySiteName}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile user profile section with navigation links
|
||||
*/
|
||||
interface MobileUserProfileProps {
|
||||
user: AuthUser | null
|
||||
onNavigate?: () => void
|
||||
}
|
||||
|
||||
function MobileUserProfile({ user, onNavigate }: MobileUserProfileProps) {
|
||||
const { t } = useTranslation()
|
||||
const [signOutOpen, setSignOutOpen] = useDialogState()
|
||||
const { displayName, initials, roleLabel } = useUserDisplay(user)
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* User info section - compact style matching navigation */}
|
||||
<div className='flex flex-col text-sm'>
|
||||
{/* User header - simplified */}
|
||||
<div className='border-border flex items-center gap-2.5 border-b p-2.5'>
|
||||
<Avatar className='size-9'>
|
||||
<AvatarImage src='/avatars/01.png' alt={`@${displayName}`} />
|
||||
<AvatarFallback className='text-xs'>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='flex flex-1 flex-col gap-0.5 overflow-hidden'>
|
||||
<p className='text-foreground truncate font-medium'>
|
||||
{displayName}
|
||||
</p>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='text-muted-foreground text-xs'>{roleLabel}</span>
|
||||
{user.group && (
|
||||
<>
|
||||
<span className='text-muted-foreground text-xs'>·</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{String(user.group)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation links - same style as top nav */}
|
||||
<Link
|
||||
to='/profile'
|
||||
onClick={onNavigate}
|
||||
className='text-primary/60 hover:text-primary/80 border-border flex items-center gap-2.5 border-b p-2.5 transition-colors'
|
||||
>
|
||||
<User className='size-4' />
|
||||
{t('Profile')}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to='/wallet'
|
||||
onClick={onNavigate}
|
||||
className='text-primary/60 hover:text-primary/80 border-border flex items-center gap-2.5 border-b p-2.5 transition-colors'
|
||||
>
|
||||
<Wallet className='size-4' />
|
||||
{t('Wallet')}
|
||||
</Link>
|
||||
|
||||
{/* Sign out - consistent style */}
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setSignOutOpen(true)}
|
||||
className='text-destructive hover:text-destructive/80 h-auto w-full justify-start gap-2.5 p-2.5 hover:bg-transparent'
|
||||
>
|
||||
<LogOut className='size-4' />
|
||||
{t('Sign out')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SignOutDialog open={!!signOutOpen} onOpenChange={setSignOutOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile sign in button for unauthenticated users
|
||||
*/
|
||||
interface MobileSignInButtonProps {
|
||||
onNavigate?: () => void
|
||||
}
|
||||
|
||||
function MobileSignInButton({ onNavigate }: MobileSignInButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button variant='secondary' size='sm' asChild className='h-10 w-full'>
|
||||
<Link to='/sign-in' onClick={onNavigate}>
|
||||
{t('Sign in')}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile drawer component props
|
||||
*/
|
||||
export interface MobileDrawerProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
homeUrl: string
|
||||
displayLogo: React.ReactNode
|
||||
displaySiteName: string
|
||||
loading: boolean
|
||||
logoLoaded: boolean
|
||||
mobileLinksList: TopNavLink[]
|
||||
showAuthButtons: boolean
|
||||
user: AuthUser | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile drawer component with bottom slide-up animation
|
||||
* Displays navigation links and user profile section
|
||||
*/
|
||||
export function MobileDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
homeUrl,
|
||||
displayLogo,
|
||||
displaySiteName,
|
||||
loading,
|
||||
logoLoaded,
|
||||
mobileLinksList,
|
||||
showAuthButtons,
|
||||
user,
|
||||
}: MobileDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<motion.div
|
||||
className={MOBILE_DRAWER_CONFIG.overlayClassName}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
variants={MOBILE_DRAWER_ANIMATION.overlay as Variants}
|
||||
transition={{
|
||||
duration: MOBILE_DRAWER_CONFIG.overlayTransitionDuration,
|
||||
}}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Drawer Content */}
|
||||
<motion.div
|
||||
className={MOBILE_DRAWER_CONFIG.drawerClassName}
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
variants={MOBILE_DRAWER_ANIMATION.drawer as Variants}
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* Header with logo and close button */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<BrandLogo
|
||||
homeUrl={homeUrl}
|
||||
displayLogo={displayLogo}
|
||||
displaySiteName={displaySiteName}
|
||||
loading={loading}
|
||||
logoLoaded={logoLoaded}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={onClose}
|
||||
className='hover:text-primary cursor-pointer'
|
||||
aria-label={t('Close menu')}
|
||||
>
|
||||
<X className='size-5' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation links */}
|
||||
<motion.div
|
||||
className='border-border mb-4 flex flex-col rounded-md border text-sm'
|
||||
variants={{ hidden: { opacity: 0 }, visible: { opacity: 1 } }}
|
||||
>
|
||||
{loading ? (
|
||||
<div className='flex flex-col gap-1 p-2'>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className='h-8 w-full' />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<AnimatePresence>
|
||||
{mobileLinksList.map((link, index) => (
|
||||
<motion.div
|
||||
key={`${link.href}-${index}`}
|
||||
className='border-border border-b p-2.5 last:border-b-0'
|
||||
variants={MOBILE_DRAWER_ANIMATION.menuItem as Variants}
|
||||
>
|
||||
<Link
|
||||
to={link.href}
|
||||
className='text-primary/60 hover:text-primary/80 transition-colors'
|
||||
onClick={onClose}
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* User profile section */}
|
||||
{showAuthButtons &&
|
||||
(user ? (
|
||||
<MobileUserProfile user={user} onNavigate={onClose} />
|
||||
) : (
|
||||
<MobileSignInButton onNavigate={onClose} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const mockupVariants = cva(
|
||||
'flex relative z-10 overflow-hidden shadow-2xl border border-border/70 dark:border-border/5 dark:border-t-border/15',
|
||||
{
|
||||
variants: {
|
||||
type: {
|
||||
mobile: 'rounded-[48px] max-w-[350px]',
|
||||
responsive: 'rounded-md',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
type: 'responsive',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface MockupProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof mockupVariants> {}
|
||||
|
||||
export function Mockup({ className, type, ...props }: MockupProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot='mockup'
|
||||
className={cn(mockupVariants({ type, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const frameVariants = cva(
|
||||
'bg-border/50 flex relative z-10 overflow-hidden rounded-2xl dark:bg-border/10',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'p-2',
|
||||
large: 'p-4',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'small',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface MockupFrameProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof frameVariants> {}
|
||||
|
||||
export function MockupFrame({ className, size, ...props }: MockupFrameProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot='mockup-frame'
|
||||
className={cn(frameVariants({ size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { type ReactNode, useState, useEffect } from 'react'
|
||||
import { Link, useLocation } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { checkIsActive } from '../lib/url-utils'
|
||||
import {
|
||||
type NavCollapsible,
|
||||
type NavChatPresets,
|
||||
type NavLink,
|
||||
type NavGroup as NavGroupProps,
|
||||
} from '../types'
|
||||
import { ChatPresetsItem } from './chat-presets-item'
|
||||
|
||||
/**
|
||||
* Sidebar navigation group component
|
||||
* Renders a group of navigation items, supporting regular links and collapsible submenus
|
||||
*/
|
||||
export function NavGroup({ title, items }: NavGroupProps) {
|
||||
const { state, isMobile } = useSidebar()
|
||||
const href = useLocation({ select: (location) => location.href })
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{title}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const key = `${item.title}-${item.url || item.type}`
|
||||
|
||||
// Special handling: dynamic chat presets list
|
||||
if (item.type === 'chat-presets') {
|
||||
return <ChatPresetsItem key={key} item={item as NavChatPresets} />
|
||||
}
|
||||
|
||||
// If no sub-items, render regular link
|
||||
if (!item.items) {
|
||||
return (
|
||||
<SidebarMenuLink key={key} item={item as NavLink} href={href} />
|
||||
)
|
||||
}
|
||||
|
||||
// In collapsed state on non-mobile, render dropdown menu
|
||||
if (state === 'collapsed' && !isMobile) {
|
||||
return (
|
||||
<SidebarMenuCollapsedDropdown
|
||||
key={key}
|
||||
item={item as NavCollapsible}
|
||||
href={href}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Render collapsible menu
|
||||
return (
|
||||
<SidebarMenuCollapsible
|
||||
key={key}
|
||||
item={item as NavCollapsible}
|
||||
href={href}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation badge component
|
||||
*/
|
||||
function NavBadge({ children }: { children: ReactNode }) {
|
||||
return <Badge className='rounded-full px-1 py-0 text-xs'>{children}</Badge>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar menu link item
|
||||
*/
|
||||
function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={checkIsActive(href, item)}
|
||||
tooltip={item.title}
|
||||
>
|
||||
<Link to={item.url} onClick={() => setOpenMobile(false)}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar collapsible menu item
|
||||
*/
|
||||
function SidebarMenuCollapsible({
|
||||
item,
|
||||
href,
|
||||
}: {
|
||||
item: NavCollapsible
|
||||
href: string
|
||||
}) {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
// 检查当前路径是否匹配子菜单项
|
||||
const isSubItemActive = checkIsActive(href, item)
|
||||
// 使用受控状态,初始值基于当前路径是否匹配
|
||||
const [isOpen, setIsOpen] = useState(() => isSubItemActive)
|
||||
|
||||
// 当路径变化时,如果匹配子菜单项,自动展开父级菜单
|
||||
useEffect(() => {
|
||||
if (isSubItemActive) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsOpen(true)
|
||||
}
|
||||
}, [isSubItemActive])
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
asChild
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
className='group/collapsible'
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='CollapsibleContent'>
|
||||
<SidebarMenuSub>
|
||||
{item.items.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={checkIsActive(href, subItem)}
|
||||
>
|
||||
<Link to={subItem.url} onClick={() => setOpenMobile(false)}>
|
||||
{subItem.icon && <subItem.icon />}
|
||||
<span>{subItem.title}</span>
|
||||
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar dropdown menu item when collapsed
|
||||
*/
|
||||
function SidebarMenuCollapsedDropdown({
|
||||
item,
|
||||
href,
|
||||
}: {
|
||||
item: NavCollapsible
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={checkIsActive(href, item)}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side='right' align='start' sideOffset={4}>
|
||||
<DropdownMenuLabel>
|
||||
{item.title} {item.badge ? `(${item.badge})` : ''}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{item.items.map((sub) => (
|
||||
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
|
||||
<Link
|
||||
to={sub.url}
|
||||
className={`${checkIsActive(href, sub) ? 'bg-secondary' : ''}`}
|
||||
>
|
||||
{sub.icon && <sub.icon />}
|
||||
<span className='max-w-52 text-wrap'>{sub.title}</span>
|
||||
{sub.badge && (
|
||||
<span className='ms-auto text-xs'>{sub.badge}</span>
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TopNavLink } from '../types'
|
||||
|
||||
interface NavLinkItemProps {
|
||||
link: TopNavLink
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single navigation link (internal or external)
|
||||
* Handles routing and proper link attributes
|
||||
*/
|
||||
export function NavLinkItem({ link, className }: NavLinkItemProps) {
|
||||
const linkClassName = cn(
|
||||
'text-muted-foreground hover:text-foreground transition-colors',
|
||||
link.disabled && 'pointer-events-none opacity-50',
|
||||
className
|
||||
)
|
||||
|
||||
if (link.external) {
|
||||
return (
|
||||
<a
|
||||
href={link.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={linkClassName}
|
||||
aria-disabled={link.disabled}
|
||||
>
|
||||
{link.title}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={link.href} className={linkClassName} disabled={link.disabled}>
|
||||
{link.title}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface NavLinkListProps {
|
||||
links: TopNavLink[]
|
||||
className?: string
|
||||
itemClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a list of navigation links
|
||||
* Used in both desktop and mobile navigation
|
||||
*/
|
||||
export function NavLinkList({
|
||||
links,
|
||||
className,
|
||||
itemClassName,
|
||||
}: NavLinkListProps) {
|
||||
return (
|
||||
<>
|
||||
{links.map((link, index) => (
|
||||
<NavLinkItem
|
||||
key={index}
|
||||
link={link}
|
||||
className={cn(className, itemClassName)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Navbar({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||
return (
|
||||
<nav
|
||||
data-slot='navbar'
|
||||
className={cn('flex items-center justify-between py-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavbarLeft({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='navbar-left'
|
||||
className={cn('flex items-center justify-start gap-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavbarRight({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='navbar-right'
|
||||
className={cn('flex items-center justify-end gap-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavbarCenter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='navbar-center'
|
||||
className={cn('flex items-center justify-center gap-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
const PageFooterContext = createContext<HTMLDivElement | null>(null)
|
||||
|
||||
type PageFooterProviderProps = {
|
||||
container: HTMLDivElement | null
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function PageFooterProvider(props: PageFooterProviderProps) {
|
||||
return (
|
||||
<PageFooterContext.Provider value={props.container}>
|
||||
{props.children}
|
||||
</PageFooterContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageFooterPortal(props: { children: ReactNode }) {
|
||||
const container = useContext(PageFooterContext)
|
||||
if (!container) return null
|
||||
return createPortal(props.children, container)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNotifications } from '@/hooks/use-notifications'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { NotificationButton } from '@/components/notification-button'
|
||||
import { NotificationDialog } from '@/components/notification-dialog'
|
||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||
import { ThemeSwitch } from '@/components/theme-switch'
|
||||
import { defaultTopNavLinks } from '../config/top-nav.config'
|
||||
import type { TopNavLink } from '../types'
|
||||
import { HeaderLogo } from './header-logo'
|
||||
|
||||
export interface PublicHeaderProps {
|
||||
navLinks?: TopNavLink[]
|
||||
mobileLinks?: TopNavLink[]
|
||||
navContent?: React.ReactNode
|
||||
showThemeSwitch?: boolean
|
||||
showLanguageSwitcher?: boolean
|
||||
logo?: React.ReactNode
|
||||
siteName?: string
|
||||
homeUrl?: string
|
||||
leftContent?: React.ReactNode
|
||||
rightContent?: React.ReactNode
|
||||
showNavigation?: boolean
|
||||
showAuthButtons?: boolean
|
||||
showNotifications?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PublicHeader(props: PublicHeaderProps) {
|
||||
const {
|
||||
navLinks = defaultTopNavLinks,
|
||||
showThemeSwitch = true,
|
||||
showLanguageSwitcher = true,
|
||||
logo: customLogo,
|
||||
siteName: customSiteName,
|
||||
homeUrl = '/',
|
||||
showAuthButtons = true,
|
||||
showNotifications = true,
|
||||
} = props
|
||||
|
||||
const { t } = useTranslation()
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const { auth } = useAuthStore()
|
||||
const {
|
||||
systemName,
|
||||
logo: systemLogo,
|
||||
loading,
|
||||
logoLoaded,
|
||||
} = useSystemConfig()
|
||||
const dynamicLinks = useTopNavLinks()
|
||||
const notifications = useNotifications()
|
||||
const routerState = useRouterState()
|
||||
const pathname = routerState.location.pathname
|
||||
|
||||
const user = auth.user
|
||||
const isAuthenticated = !!user
|
||||
const displaySiteName = customSiteName || systemName
|
||||
const links = dynamicLinks.length > 0 ? dynamicLinks : navLinks
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 20)
|
||||
onScroll()
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = mobileOpen ? 'hidden' : ''
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [mobileOpen])
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className='pointer-events-none fixed inset-x-0 top-0 z-50'>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-auto mx-auto transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]',
|
||||
scrolled ? 'max-w-[52rem] px-3 pt-3' : 'max-w-7xl px-4 pt-0 md:px-6'
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
className={cn(
|
||||
'flex items-center justify-between transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]',
|
||||
scrolled
|
||||
? 'bg-background/60 ring-border/50 h-12 rounded-2xl pr-1.5 pl-4 shadow-[0_2px_16px_-6px_rgba(0,0,0,0.08),0_0_0_0.5px_rgba(0,0,0,0.02)] ring-[0.5px] backdrop-blur-2xl dark:shadow-[0_2px_16px_-6px_rgba(0,0,0,0.4)]'
|
||||
: 'h-16 px-2'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link
|
||||
to={homeUrl}
|
||||
className='group flex shrink-0 items-center gap-2.5'
|
||||
>
|
||||
<div className='flex size-7 shrink-0 items-center justify-center transition-all duration-300 group-hover:scale-105'>
|
||||
{loading ? (
|
||||
<Skeleton className='size-full rounded-lg' />
|
||||
) : customLogo ? (
|
||||
customLogo
|
||||
) : (
|
||||
<HeaderLogo
|
||||
src={systemLogo}
|
||||
loading={loading}
|
||||
logoLoaded={logoLoaded}
|
||||
className='size-full rounded-lg object-contain'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className='text-sm font-semibold tracking-tight'>
|
||||
{loading ? <Skeleton className='h-4 w-16' /> : displaySiteName}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<div className='hidden items-center gap-0.5 sm:flex'>
|
||||
{links.map((link, i) => {
|
||||
const isActive = pathname === link.href
|
||||
if (link.external) {
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={link.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-muted-foreground hover:text-foreground rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200'
|
||||
>
|
||||
{t(link.title)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
to={link.href}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t(link.title)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
{(showLanguageSwitcher ||
|
||||
showThemeSwitch ||
|
||||
showNotifications) && (
|
||||
<div className='bg-border/40 mx-2 h-4 w-px' />
|
||||
)}
|
||||
|
||||
{showLanguageSwitcher && <LanguageSwitcher />}
|
||||
{showThemeSwitch && <ThemeSwitch />}
|
||||
{showNotifications && (
|
||||
<NotificationButton
|
||||
unreadCount={notifications.unreadCount}
|
||||
onClick={() => notifications.openDialog()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAuthButtons && (
|
||||
<>
|
||||
<div className='bg-border/40 mx-1 h-4 w-px' />
|
||||
{loading ? (
|
||||
<Skeleton className='h-8 w-20 rounded-lg' />
|
||||
) : isAuthenticated ? (
|
||||
<ProfileDropdown />
|
||||
) : (
|
||||
<Button
|
||||
size='sm'
|
||||
className='h-8 rounded-lg px-3.5 text-xs font-medium'
|
||||
asChild
|
||||
>
|
||||
<Link to='/sign-in'>{t('Sign in')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: compact actions + hamburger */}
|
||||
<div className='flex items-center gap-2 sm:hidden'>
|
||||
{showThemeSwitch && <ThemeSwitch />}
|
||||
{showAuthButtons && !loading && isAuthenticated && (
|
||||
<ProfileDropdown />
|
||||
)}
|
||||
<button
|
||||
className='hover:bg-muted/40 flex size-9 items-center justify-center rounded-lg transition-colors'
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
aria-label={t('Toggle navigation menu')}
|
||||
>
|
||||
<div className='relative size-4'>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-x-0 block h-[1.5px] origin-center rounded-full bg-current transition-all duration-300',
|
||||
mobileOpen ? 'top-[7px] rotate-45' : 'top-[3px]'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-x-0 top-[7px] block h-[1.5px] rounded-full bg-current transition-all duration-300',
|
||||
mobileOpen ? 'scale-x-0 opacity-0' : 'opacity-100'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-x-0 block h-[1.5px] origin-center rounded-full bg-current transition-all duration-300',
|
||||
mobileOpen ? 'top-[7px] -rotate-45' : 'top-[11px]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile full-screen overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background/98 fixed inset-0 z-40 backdrop-blur-2xl transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] sm:pointer-events-none sm:hidden',
|
||||
mobileOpen
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className='flex h-full flex-col justify-between px-8 pt-20 pb-10'>
|
||||
<nav className='flex flex-col gap-1'>
|
||||
{links.map((link, i) => {
|
||||
const isActive = pathname === link.href
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
to={link.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 py-3 text-base font-medium tracking-tight transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
|
||||
mobileOpen
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'translate-y-4 opacity-0',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
style={{
|
||||
transitionDelay: mobileOpen ? `${100 + i * 50}ms` : '0ms',
|
||||
}}
|
||||
>
|
||||
{t(link.title)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-3 transition-all duration-500',
|
||||
mobileOpen
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'translate-y-4 opacity-0'
|
||||
)}
|
||||
style={{ transitionDelay: mobileOpen ? '250ms' : '0ms' }}
|
||||
>
|
||||
{showAuthButtons && (
|
||||
<Link
|
||||
to={isAuthenticated ? '/dashboard' : '/sign-in'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className='bg-foreground text-background inline-flex h-10 items-center justify-center rounded-lg text-sm font-medium transition-opacity hover:opacity-90 active:opacity-80'
|
||||
>
|
||||
{isAuthenticated ? t('Go to Dashboard') : t('Sign in')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Dialog */}
|
||||
{showNotifications && (
|
||||
<NotificationDialog
|
||||
open={notifications.dialogOpen}
|
||||
onOpenChange={notifications.setDialogOpen}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
onCloseToday={notifications.closeToday}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { TopNavLink } from '../types'
|
||||
import { PublicHeader, type PublicHeaderProps } from './public-header'
|
||||
|
||||
type PublicLayoutProps = {
|
||||
children: React.ReactNode
|
||||
showMainContainer?: boolean
|
||||
navContent?: React.ReactNode
|
||||
headerProps?: Omit<PublicHeaderProps, 'navContent'>
|
||||
navLinks?: TopNavLink[]
|
||||
showThemeSwitch?: boolean
|
||||
showAuthButtons?: boolean
|
||||
showNotifications?: boolean
|
||||
logo?: React.ReactNode
|
||||
siteName?: string
|
||||
}
|
||||
|
||||
export function PublicLayout(props: PublicLayoutProps) {
|
||||
return (
|
||||
<div className='bg-background text-foreground relative min-h-svh overflow-hidden'>
|
||||
<PublicHeader
|
||||
navContent={props.navContent}
|
||||
navLinks={props.navLinks}
|
||||
showThemeSwitch={props.showThemeSwitch}
|
||||
showAuthButtons={props.showAuthButtons}
|
||||
showNotifications={props.showNotifications}
|
||||
logo={props.logo}
|
||||
siteName={props.siteName}
|
||||
{...props.headerProps}
|
||||
/>
|
||||
|
||||
{props.showMainContainer !== false ? (
|
||||
<main className='container px-4 py-6 pt-20 md:px-4'>
|
||||
{props.children}
|
||||
</main>
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
|
||||
import { defaultTopNavLinks } from '../config/top-nav.config'
|
||||
import type { TopNavLink } from '../types'
|
||||
|
||||
interface PublicNavigationProps {
|
||||
/**
|
||||
* Custom navigation links
|
||||
* If not provided, will use dynamic links from backend or defaults
|
||||
*/
|
||||
links?: TopNavLink[]
|
||||
/**
|
||||
* Additional className
|
||||
*/
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Public navigation component that matches Launch UI template styling
|
||||
* Used in PublicHeader for desktop navigation
|
||||
*/
|
||||
export function PublicNavigation({
|
||||
links: providedLinks,
|
||||
className,
|
||||
}: PublicNavigationProps = {}) {
|
||||
// Use the same logic as AppHeader: prioritize dynamic links from backend
|
||||
const dynamicLinks = useTopNavLinks()
|
||||
const defaultLinks = providedLinks || defaultTopNavLinks
|
||||
const links = dynamicLinks.length > 0 ? dynamicLinks : defaultLinks
|
||||
|
||||
return (
|
||||
<nav className={cn('hidden items-center gap-1 md:flex', className)}>
|
||||
{links.map((link, index) => {
|
||||
// Handle external links
|
||||
if (link.external) {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={link.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={cn(
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground inline-flex h-9 w-max items-center justify-center rounded-md bg-transparent px-4 py-2 text-sm font-medium transition-colors focus:outline-none',
|
||||
link.disabled && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
{link.title}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
// Handle internal links
|
||||
return (
|
||||
<Link
|
||||
key={index}
|
||||
to={link.href}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground inline-flex h-9 w-max items-center justify-center rounded-md bg-transparent px-4 py-2 text-sm font-medium transition-colors focus:outline-none',
|
||||
link.disabled && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { AppHeader } from './app-header'
|
||||
import { Main } from './main'
|
||||
import { PageFooterProvider } from './page-footer'
|
||||
|
||||
type SlotProps = { children?: ReactNode }
|
||||
|
||||
function SectionPageLayoutTitle(_props: SlotProps) {
|
||||
return null
|
||||
}
|
||||
SectionPageLayoutTitle.displayName = 'SectionPageLayout.Title'
|
||||
|
||||
function SectionPageLayoutDescription(_props: SlotProps) {
|
||||
return null
|
||||
}
|
||||
SectionPageLayoutDescription.displayName = 'SectionPageLayout.Description'
|
||||
|
||||
function SectionPageLayoutActions(_props: SlotProps) {
|
||||
return null
|
||||
}
|
||||
SectionPageLayoutActions.displayName = 'SectionPageLayout.Actions'
|
||||
|
||||
function SectionPageLayoutContent(_props: SlotProps) {
|
||||
return null
|
||||
}
|
||||
SectionPageLayoutContent.displayName = 'SectionPageLayout.Content'
|
||||
|
||||
function SectionPageLayoutBreadcrumb(_props: SlotProps) {
|
||||
return null
|
||||
}
|
||||
SectionPageLayoutBreadcrumb.displayName = 'SectionPageLayout.Breadcrumb'
|
||||
|
||||
export type SectionPageLayoutProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SectionPageLayout(props: SectionPageLayoutProps) {
|
||||
const [footerContainer, setFooterContainer] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
)
|
||||
|
||||
let title: ReactNode = null
|
||||
let description: ReactNode = null
|
||||
let actions: ReactNode = null
|
||||
let content: ReactNode = null
|
||||
let breadcrumb: ReactNode = null
|
||||
|
||||
Children.forEach(props.children, (node) => {
|
||||
if (!isValidElement(node)) return
|
||||
const child = node as ReactElement<SlotProps>
|
||||
if (child.type === SectionPageLayoutTitle) title = child.props.children
|
||||
else if (child.type === SectionPageLayoutDescription)
|
||||
description = child.props.children
|
||||
else if (child.type === SectionPageLayoutActions)
|
||||
actions = child.props.children
|
||||
else if (child.type === SectionPageLayoutContent)
|
||||
content = child.props.children
|
||||
else if (child.type === SectionPageLayoutBreadcrumb)
|
||||
breadcrumb = child.props.children
|
||||
})
|
||||
|
||||
return (
|
||||
<PageFooterProvider container={footerContainer}>
|
||||
<AppHeader />
|
||||
|
||||
<Main>
|
||||
<div className='shrink-0 px-4 pt-4 pb-3 sm:pt-6 sm:pb-4'>
|
||||
{breadcrumb != null && <div className='mb-3'>{breadcrumb}</div>}
|
||||
<div className='flex flex-wrap items-center justify-between gap-x-4 gap-y-2'>
|
||||
<div className='min-w-0'>
|
||||
<h2 className='text-base font-bold tracking-tight sm:text-lg'>
|
||||
{title}
|
||||
</h2>
|
||||
{description != null && (
|
||||
<p className='text-muted-foreground max-sm:text-xs sm:text-sm'>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions != null && (
|
||||
<div className='flex shrink-0 flex-wrap items-center gap-2 sm:gap-x-4'>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-auto px-4 pb-4'>{content}</div>
|
||||
|
||||
<div
|
||||
ref={setFooterContainer}
|
||||
className='bg-background shrink-0 border-t px-4 py-3 empty:hidden'
|
||||
/>
|
||||
</Main>
|
||||
</PageFooterProvider>
|
||||
)
|
||||
}
|
||||
|
||||
SectionPageLayout.Title = SectionPageLayoutTitle
|
||||
SectionPageLayout.Description = SectionPageLayoutDescription
|
||||
SectionPageLayout.Actions = SectionPageLayoutActions
|
||||
SectionPageLayout.Content = SectionPageLayoutContent
|
||||
SectionPageLayout.Breadcrumb = SectionPageLayoutBreadcrumb
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Section({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'section'>) {
|
||||
return (
|
||||
<section
|
||||
data-slot='section'
|
||||
className={cn(
|
||||
'bg-background text-foreground px-4 py-6 sm:py-12 md:py-20',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Menu } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { type TopNavLink } from '../types'
|
||||
|
||||
type TopNavProps = React.HTMLAttributes<HTMLElement> & {
|
||||
links: TopNavLink[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部导航栏组件
|
||||
* 在大屏幕显示水平导航,在小屏幕显示下拉菜单
|
||||
*/
|
||||
export function TopNav({ className, links, ...props }: TopNavProps) {
|
||||
// 规范化链接,确保所有可选属性都有默认值
|
||||
const normalizedLinks = useMemo(
|
||||
() =>
|
||||
links.map((link) => ({
|
||||
isActive: false,
|
||||
disabled: false,
|
||||
external: false,
|
||||
...link,
|
||||
})),
|
||||
[links]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 移动端下拉菜单 */}
|
||||
<div className='lg:hidden'>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size='icon' variant='outline' className='size-7'>
|
||||
<Menu />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side='bottom' align='start'>
|
||||
{normalizedLinks.map(
|
||||
({ title, href, isActive, disabled, external }) => (
|
||||
<DropdownMenuItem key={`${title}-${href}`} asChild>
|
||||
{external ? (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={!isActive ? 'text-muted-foreground' : ''}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
to={href}
|
||||
className={!isActive ? 'text-muted-foreground' : ''}
|
||||
disabled={disabled}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* 桌面端水平导航 */}
|
||||
<nav
|
||||
className={cn(
|
||||
'hidden items-center space-x-4 lg:flex lg:space-x-4 xl:space-x-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{normalizedLinks.map(({ title, href, isActive, disabled, external }) =>
|
||||
external ? (
|
||||
<a
|
||||
key={`${title}-${href}`}
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`hover:text-primary text-sm font-medium transition-colors ${isActive ? '' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
key={`${title}-${href}`}
|
||||
to={href}
|
||||
disabled={disabled}
|
||||
className={`hover:text-primary text-sm font-medium transition-colors ${isActive ? '' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import * as React from 'react'
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router'
|
||||
import { ChevronsUpDown } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useWorkspace } from '../context/workspace-context'
|
||||
import { getWorkspaceByPath, WORKSPACE_IDS } from '../lib/workspace-registry'
|
||||
import { type Workspace } from '../types'
|
||||
|
||||
type WorkspaceSwitcherProps = {
|
||||
workspaces: Workspace[]
|
||||
defaultName?: string
|
||||
defaultVersion?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace switcher component
|
||||
* Allows users to switch between different workspaces
|
||||
* - Regular users can only see the default workspace
|
||||
* - Super administrators can see the system settings workspace
|
||||
*/
|
||||
export function WorkspaceSwitcher({
|
||||
workspaces,
|
||||
defaultName = 'New API',
|
||||
defaultVersion,
|
||||
}: WorkspaceSwitcherProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const { isMobile } = useSidebar()
|
||||
const { status } = useStatus()
|
||||
const { logo } = useSystemConfig()
|
||||
const isSuperAdmin = useAuthStore(
|
||||
(state) => state.auth.user?.role === ROLE.SUPER_ADMIN
|
||||
)
|
||||
const { activeWorkspace, setActiveWorkspace } = useWorkspace()
|
||||
|
||||
// Handle workspace list:
|
||||
// 1. Populate first workspace with system info
|
||||
// 2. Filter based on user permissions (non-super admins cannot see system settings)
|
||||
const availableWorkspaces = React.useMemo(
|
||||
() =>
|
||||
workspaces
|
||||
.map((workspace, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...workspace,
|
||||
name: status?.system_name || defaultName,
|
||||
plan: status?.version || defaultVersion || t('Unknown version'),
|
||||
}
|
||||
: workspace
|
||||
)
|
||||
.filter(
|
||||
(workspace) =>
|
||||
isSuperAdmin || workspace.id !== WORKSPACE_IDS.SYSTEM_SETTINGS
|
||||
),
|
||||
[
|
||||
workspaces,
|
||||
status?.system_name,
|
||||
status?.version,
|
||||
defaultName,
|
||||
defaultVersion,
|
||||
isSuperAdmin,
|
||||
t,
|
||||
]
|
||||
)
|
||||
|
||||
// Initialize and synchronize active workspace
|
||||
// Detect from URL first, then sync from activeWorkspace
|
||||
React.useEffect(() => {
|
||||
// Detect which workspace should be active from workspace registry
|
||||
const detectedWorkspace = getWorkspaceByPath(pathname)
|
||||
|
||||
if (detectedWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS) {
|
||||
// Currently in system settings route, should activate System Settings workspace
|
||||
const systemSettingsWorkspace = availableWorkspaces.find(
|
||||
(w) => w.id === WORKSPACE_IDS.SYSTEM_SETTINGS
|
||||
)
|
||||
if (systemSettingsWorkspace) {
|
||||
setActiveWorkspace(systemSettingsWorkspace)
|
||||
}
|
||||
} else {
|
||||
// Currently in main workspace route, should activate main workspace
|
||||
const mainWorkspace =
|
||||
availableWorkspaces.find((w) => w.id === WORKSPACE_IDS.DEFAULT) ||
|
||||
availableWorkspaces[0]
|
||||
if (mainWorkspace) {
|
||||
setActiveWorkspace(mainWorkspace)
|
||||
}
|
||||
}
|
||||
}, [pathname, availableWorkspaces, setActiveWorkspace])
|
||||
|
||||
const handleWorkspaceChange = (workspace: Workspace) => {
|
||||
// Only navigate, let useEffect synchronize workspace state based on new pathname
|
||||
// This avoids race conditions and context loss issues
|
||||
if (workspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS) {
|
||||
navigate({ to: '/system-settings/general' })
|
||||
} else {
|
||||
navigate({ to: '/dashboard' })
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeWorkspace) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
{activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? (
|
||||
<div className='bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'>
|
||||
<activeWorkspace.logo className='size-4' />
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg'>
|
||||
<img
|
||||
src={logo}
|
||||
alt={t('Logo')}
|
||||
className='size-full rounded-lg object-cover'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='grid flex-1 text-start text-sm leading-tight group-data-[collapsible=icon]:hidden'>
|
||||
<span className='truncate font-semibold'>
|
||||
{activeWorkspace.name}
|
||||
</span>
|
||||
<span className='truncate text-xs'>{activeWorkspace.plan}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className='ms-auto group-data-[collapsible=icon]:hidden' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
||||
align='start'
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className='text-muted-foreground text-xs'>
|
||||
{t('Workspaces')}
|
||||
</DropdownMenuLabel>
|
||||
{availableWorkspaces.map((workspace, index) => (
|
||||
<DropdownMenuItem
|
||||
key={workspace.id}
|
||||
onClick={() => handleWorkspaceChange(workspace)}
|
||||
className='gap-2 p-2'
|
||||
>
|
||||
{index === 0 ? (
|
||||
<div className='flex size-6 items-center justify-center overflow-hidden rounded-sm border'>
|
||||
<img
|
||||
src={logo}
|
||||
alt='Logo'
|
||||
className='size-full object-cover'
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex size-6 items-center justify-center rounded-sm border'>
|
||||
<workspace.logo className='size-4 shrink-0' />
|
||||
</div>
|
||||
)}
|
||||
{workspace.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { type TFunction } from 'i18next'
|
||||
import {
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
Layout,
|
||||
Plug,
|
||||
Box,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
import { getAuthSectionNavItems } from '@/features/system-settings/auth/section-registry.tsx'
|
||||
import { getContentSectionNavItems } from '@/features/system-settings/content/section-registry.tsx'
|
||||
import { getGeneralSectionNavItems } from '@/features/system-settings/general/section-registry.tsx'
|
||||
import { getIntegrationsSectionNavItems } from '@/features/system-settings/integrations/section-registry.tsx'
|
||||
import { getMaintenanceSectionNavItems } from '@/features/system-settings/maintenance/section-registry.tsx'
|
||||
import { getModelsSectionNavItems } from '@/features/system-settings/models/section-registry.tsx'
|
||||
import { getRequestLimitsSectionNavItems } from '@/features/system-settings/request-limits/section-registry.tsx'
|
||||
import { type NavGroup } from '../types'
|
||||
|
||||
/**
|
||||
* System settings sidebar configuration
|
||||
* Displayed when switching to "System Settings" workspace
|
||||
*/
|
||||
export const WORKSPACE_SYSTEM_SETTINGS_ID = 'system-settings'
|
||||
|
||||
export function getSystemSettingsNavGroups(t: TFunction): NavGroup[] {
|
||||
return [
|
||||
{
|
||||
id: 'system-administration',
|
||||
title: t('System Administration'),
|
||||
items: [
|
||||
{
|
||||
title: t('General'),
|
||||
icon: Settings,
|
||||
items: getGeneralSectionNavItems(t),
|
||||
},
|
||||
{
|
||||
title: t('Authentication'),
|
||||
icon: Shield,
|
||||
items: getAuthSectionNavItems(t),
|
||||
},
|
||||
{
|
||||
title: t('Request Limits'),
|
||||
icon: ShieldAlert,
|
||||
items: getRequestLimitsSectionNavItems(t),
|
||||
},
|
||||
{
|
||||
title: t('Content'),
|
||||
icon: Layout,
|
||||
items: getContentSectionNavItems(t),
|
||||
},
|
||||
{
|
||||
title: t('Integrations'),
|
||||
icon: Plug,
|
||||
items: getIntegrationsSectionNavItems(t),
|
||||
},
|
||||
{
|
||||
title: t('Models'),
|
||||
icon: Box,
|
||||
items: getModelsSectionNavItems(t),
|
||||
},
|
||||
{
|
||||
title: t('Maintenance'),
|
||||
icon: Wrench,
|
||||
items: getMaintenanceSectionNavItems(t),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type TopNavLink } from '../types'
|
||||
|
||||
/**
|
||||
* Default top navigation links
|
||||
*
|
||||
* In practice, navigation links are dynamically fetched from backend.
|
||||
* Priority: Backend dynamic links > Provided navLinks > defaultTopNavLinks
|
||||
*
|
||||
* This is intentionally empty to encourage backend configuration.
|
||||
* If you need fallback links, add them here.
|
||||
*/
|
||||
export const defaultTopNavLinks: TopNavLink[] = []
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Layout constants and configurations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Animation variants for mobile drawer
|
||||
*/
|
||||
export const MOBILE_DRAWER_ANIMATION = {
|
||||
overlay: {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
},
|
||||
drawer: {
|
||||
hidden: { opacity: 0, y: 100 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
rotate: 0,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
damping: 15,
|
||||
stiffness: 200,
|
||||
staggerChildren: 0.03,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: 100,
|
||||
transition: { duration: 0.1 },
|
||||
},
|
||||
},
|
||||
menuItem: {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
},
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Mobile drawer configuration
|
||||
*/
|
||||
export const MOBILE_DRAWER_CONFIG = {
|
||||
overlayTransitionDuration: 0.2,
|
||||
drawerClassName:
|
||||
'fixed inset-x-0 bottom-3 z-50 mx-auto w-[95%] rounded-xl border border-border bg-background p-4 shadow-lg md:hidden',
|
||||
overlayClassName: 'fixed inset-0 z-40 bg-black/50 backdrop-blur-sm',
|
||||
} as const
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from 'react'
|
||||
import { type Workspace } from '../types'
|
||||
|
||||
type WorkspaceContextType = {
|
||||
activeWorkspace: Workspace | null
|
||||
setActiveWorkspace: (workspace: Workspace) => void
|
||||
}
|
||||
|
||||
const WorkspaceContext = React.createContext<WorkspaceContextType | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
/**
|
||||
* 工作区上下文 Provider
|
||||
* 管理当前激活的工作区状态,用于切换不同的侧边栏视图
|
||||
*/
|
||||
export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
|
||||
const [activeWorkspace, setActiveWorkspace] =
|
||||
React.useState<Workspace | null>(null)
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ activeWorkspace, setActiveWorkspace }),
|
||||
[activeWorkspace]
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkspaceContext.Provider value={value}>
|
||||
{children}
|
||||
</WorkspaceContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用工作区上下文的 Hook
|
||||
* @throws 如果在 WorkspaceProvider 外部使用会抛出错误
|
||||
*/
|
||||
export function useWorkspace() {
|
||||
const context = React.useContext(WorkspaceContext)
|
||||
if (!context) {
|
||||
throw new Error('useWorkspace must be used within WorkspaceProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Layout 组件统一导出
|
||||
*/
|
||||
|
||||
// 核心组件
|
||||
export { AppHeader } from './components/app-header'
|
||||
export { AppSidebar } from './components/app-sidebar'
|
||||
export { AuthenticatedLayout } from './components/authenticated-layout'
|
||||
export { PublicLayout } from './components/public-layout'
|
||||
export { PublicHeader } from './components/public-header'
|
||||
export { PublicNavigation } from './components/public-navigation'
|
||||
export { HeaderLogo } from './components/header-logo'
|
||||
export { NavLinkItem, NavLinkList } from './components/nav-link-item'
|
||||
export { Header } from './components/header'
|
||||
export { Main } from './components/main'
|
||||
export { PageFooterPortal } from './components/page-footer'
|
||||
export { NavGroup } from './components/nav-group'
|
||||
export { SectionPageLayout } from './components/section-page-layout'
|
||||
export { WorkspaceSwitcher } from './components/workspace-switcher'
|
||||
export { TopNav } from './components/top-nav'
|
||||
export { MobileDrawer } from './components/mobile-drawer'
|
||||
|
||||
// 上下文
|
||||
export { WorkspaceProvider, useWorkspace } from './context/workspace-context'
|
||||
|
||||
// 配置
|
||||
export {
|
||||
getSystemSettingsNavGroups,
|
||||
WORKSPACE_SYSTEM_SETTINGS_ID,
|
||||
} from './config/system-settings.config'
|
||||
export { defaultTopNavLinks } from './config/top-nav.config'
|
||||
|
||||
// 常量
|
||||
export { MOBILE_DRAWER_ANIMATION, MOBILE_DRAWER_CONFIG } from './constants'
|
||||
|
||||
// 工具函数 - 工作区注册表
|
||||
export {
|
||||
getWorkspaceByPath,
|
||||
getNavGroupsForPath,
|
||||
isInWorkspace,
|
||||
getAllWorkspaces,
|
||||
WORKSPACE_IDS,
|
||||
} from './lib/workspace-registry'
|
||||
|
||||
// 类型导出(使用 type-only 导出避免与组件冲突)
|
||||
export type {
|
||||
Workspace,
|
||||
NavLink,
|
||||
NavCollapsible,
|
||||
NavItem,
|
||||
NavGroup as NavGroupType,
|
||||
SidebarData,
|
||||
TopNavLink,
|
||||
} from './types'
|
||||
export type { WorkspaceConfig, WorkspaceId } from './lib/workspace-registry'
|
||||
export type { SectionPageLayoutProps } from './components/section-page-layout'
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { LinkProps } from '@tanstack/react-router'
|
||||
import type { NavItem, NavCollapsible } from '../types'
|
||||
|
||||
/**
|
||||
* Convert LinkProps['to'] to string
|
||||
* Handles both string URLs and object URLs (e.g., { pathname, search })
|
||||
*/
|
||||
function urlToString(url: LinkProps['to'] | (string & {})): string | null {
|
||||
if (typeof url === 'string') {
|
||||
return url
|
||||
}
|
||||
if (url && typeof url === 'object' && !Array.isArray(url)) {
|
||||
// Handle object URLs like { pathname: string, search?: string }
|
||||
const urlObj = url as Record<string, unknown>
|
||||
const pathname = typeof urlObj.pathname === 'string' ? urlObj.pathname : ''
|
||||
const search = typeof urlObj.search === 'string' ? urlObj.search : ''
|
||||
return pathname + search
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize URL by removing query parameters and trailing slashes
|
||||
*/
|
||||
export function normalizeHref(href: string): string {
|
||||
const withoutQuery = href.split('?')[0]
|
||||
return withoutQuery.length > 1
|
||||
? withoutQuery.replace(/\/+$/, '')
|
||||
: withoutQuery
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a navigation item is active
|
||||
* @param href - Current URL
|
||||
* @param item - Navigation item
|
||||
* @param mainNav - Whether this is a main navigation item (matches first-level path)
|
||||
*/
|
||||
export function checkIsActive(
|
||||
href: string,
|
||||
item: NavItem,
|
||||
mainNav = false
|
||||
): boolean {
|
||||
// For collapsible items (NavCollapsible), check sub-items first
|
||||
if ('items' in item && item.items) {
|
||||
const collapsibleItem = item as NavCollapsible
|
||||
const items = collapsibleItem.items
|
||||
const hrefWithoutQuery = href.split('?')[0]
|
||||
|
||||
// Check if any sub-item matches
|
||||
if (
|
||||
items.some((i) => {
|
||||
if (!i?.url) return false
|
||||
const subItemUrl = urlToString(i.url)
|
||||
if (!subItemUrl) return false
|
||||
if (href === subItemUrl) return true
|
||||
const subItemUrlWithoutQuery = subItemUrl.split('?')[0]
|
||||
const subItemUrlHasQuery = subItemUrl.includes('?')
|
||||
if (subItemUrlWithoutQuery === hrefWithoutQuery) {
|
||||
// If sub-item URL has no query params, pathname match is enough (href may have query params)
|
||||
if (!subItemUrlHasQuery) return true
|
||||
// If sub-item URL has query params, they must match exactly
|
||||
if (subItemUrlHasQuery && href === subItemUrl) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
// For regular link items, check the item's URL
|
||||
if (!item.url) return false
|
||||
|
||||
const itemUrl = urlToString(item.url)
|
||||
if (!itemUrl) return false
|
||||
|
||||
// Exact match
|
||||
if (href === itemUrl) return true
|
||||
|
||||
const hrefWithoutQuery = href.split('?')[0]
|
||||
const itemUrlWithoutQuery = itemUrl.split('?')[0]
|
||||
const itemUrlHasQuery = itemUrl.includes('?')
|
||||
|
||||
// If both URLs have the same base path
|
||||
if (hrefWithoutQuery === itemUrlWithoutQuery) {
|
||||
// If item.url has no query params, pathname match is enough (current URL may have query params)
|
||||
if (!itemUrlHasQuery) return true
|
||||
// If item.url has query params, they must match exactly
|
||||
if (itemUrlHasQuery && href === itemUrl) return true
|
||||
}
|
||||
|
||||
// Main navigation match (matches first-level path)
|
||||
if (mainNav && href.split('/')[1] && itemUrl) {
|
||||
const hrefFirstPath = href.split('/')[1]
|
||||
const itemFirstPath = itemUrl.split('/')[1]
|
||||
return hrefFirstPath === itemFirstPath
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 工作区注册表使用示例
|
||||
*
|
||||
* 本文件展示如何添加新工作区,仅作示例参考,不会被编译
|
||||
*/
|
||||
|
||||
/**
|
||||
* 步骤1: 创建工作区的侧边栏配置文件
|
||||
* 例如:web/src/components/layout/config/user-management.config.ts
|
||||
*/
|
||||
/*
|
||||
import { Users, UserPlus, Shield } from 'lucide-react'
|
||||
import { type NavGroup } from '../types'
|
||||
|
||||
export const userManagementConfig: NavGroup[] = [
|
||||
{
|
||||
title: 'User Management',
|
||||
items: [
|
||||
{
|
||||
title: 'All Users',
|
||||
url: '/user-management/list',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: 'Create User',
|
||||
url: '/user-management/create',
|
||||
icon: UserPlus,
|
||||
},
|
||||
{
|
||||
title: 'Permissions',
|
||||
url: '/user-management/permissions',
|
||||
icon: Shield,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
*/
|
||||
|
||||
/**
|
||||
* 步骤2: 在 workspace-registry.ts 中注册新工作区
|
||||
* 在 workspaceRegistry 数组中添加配置(在默认工作区之前)
|
||||
*/
|
||||
/*
|
||||
import { userManagementConfig } from '../config/user-management.config'
|
||||
|
||||
const workspaceRegistry: WorkspaceConfig[] = [
|
||||
// System Settings 工作区
|
||||
{
|
||||
name: 'System Settings',
|
||||
pathPattern: /^\/system-settings/,
|
||||
navGroups: systemSettingsConfig,
|
||||
},
|
||||
// 新增的 User Management 工作区
|
||||
{
|
||||
name: 'User Management',
|
||||
pathPattern: /^\/user-management/, // 或使用字符串: '/user-management'
|
||||
navGroups: userManagementConfig,
|
||||
},
|
||||
// 默认工作区(必须放在最后)
|
||||
{
|
||||
name: 'Default',
|
||||
pathPattern: /.* /,
|
||||
navGroups: sidebarConfig.navGroups,
|
||||
},
|
||||
]
|
||||
*/
|
||||
|
||||
/**
|
||||
* 步骤3: (可选)在 sidebar.config.ts 中添加工作区到切换器
|
||||
*/
|
||||
/*
|
||||
export const sidebarConfig: SidebarData = {
|
||||
workspaces: [
|
||||
{
|
||||
name: '',
|
||||
logo: Command,
|
||||
plan: '',
|
||||
},
|
||||
{
|
||||
name: 'User Management',
|
||||
logo: Users,
|
||||
plan: 'Manage users',
|
||||
},
|
||||
{
|
||||
name: 'System Settings',
|
||||
logo: Settings,
|
||||
plan: 'Manage and configure',
|
||||
},
|
||||
],
|
||||
navGroups: [...],
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* 同语注:这里就完成了,现在:
|
||||
* - 侧边栏会根据当前路径自动切换显示对应的工作区菜单
|
||||
* - 搜索功能会自动显示当前工作区的菜单项
|
||||
* - 工作区切换器会显示新的工作区选项
|
||||
*
|
||||
* 无需修改任何其他文件!
|
||||
*/
|
||||
@@ -0,0 +1,110 @@
|
||||
import { type TFunction } from 'i18next'
|
||||
import {
|
||||
getSystemSettingsNavGroups,
|
||||
WORKSPACE_SYSTEM_SETTINGS_ID,
|
||||
} from '../config/system-settings.config'
|
||||
import type { NavGroup } from '../types'
|
||||
|
||||
export const WORKSPACE_IDS = {
|
||||
SYSTEM_SETTINGS: WORKSPACE_SYSTEM_SETTINGS_ID,
|
||||
DEFAULT: 'default',
|
||||
} as const
|
||||
|
||||
export type WorkspaceId = (typeof WORKSPACE_IDS)[keyof typeof WORKSPACE_IDS]
|
||||
|
||||
/**
|
||||
* Workspace configuration type
|
||||
* Each workspace contains name, path matching rules, and corresponding navigation group configuration
|
||||
*/
|
||||
export type WorkspaceConfig = {
|
||||
/** Workspace identifier (for logic) */
|
||||
id: WorkspaceId
|
||||
/** Workspace name */
|
||||
name: string
|
||||
/** Path matching rule, supports string (contains match) or regular expression */
|
||||
pathPattern: string | RegExp
|
||||
/** Sidebar navigation group configuration for this workspace */
|
||||
getNavGroups?: (t: TFunction) => NavGroup[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace registry
|
||||
*
|
||||
* Sorted by priority, first matched workspace will be used
|
||||
* Last one should be default workspace (matches all paths)
|
||||
*
|
||||
* @example
|
||||
* // Add new workspace
|
||||
* {
|
||||
* name: 'User Management',
|
||||
* pathPattern: /^\/user-management/,
|
||||
* navGroups: userManagementConfig
|
||||
* }
|
||||
*/
|
||||
const workspaceRegistry: WorkspaceConfig[] = [
|
||||
// System Settings workspace
|
||||
{
|
||||
id: WORKSPACE_IDS.SYSTEM_SETTINGS,
|
||||
name: 'System Settings',
|
||||
pathPattern: /^\/system-settings/,
|
||||
getNavGroups: getSystemSettingsNavGroups,
|
||||
},
|
||||
// Default workspace (must be last)
|
||||
{
|
||||
id: WORKSPACE_IDS.DEFAULT,
|
||||
name: 'Default',
|
||||
pathPattern: /.*/,
|
||||
// getNavGroups is undefined, will be handled by consumers (e.g. useSidebarData)
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Get matched workspace configuration based on path
|
||||
* @param pathname - Current route path
|
||||
* @returns Matched workspace configuration
|
||||
*/
|
||||
export function getWorkspaceByPath(pathname: string): WorkspaceConfig {
|
||||
const workspace = workspaceRegistry.find((ws) => {
|
||||
if (typeof ws.pathPattern === 'string') {
|
||||
return pathname.includes(ws.pathPattern)
|
||||
}
|
||||
return ws.pathPattern.test(pathname)
|
||||
})
|
||||
|
||||
// If no match, return default workspace (last one)
|
||||
return workspace || workspaceRegistry[workspaceRegistry.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get corresponding sidebar navigation group configuration based on path
|
||||
* @param pathname - Current route path
|
||||
* @returns Navigation group configuration for corresponding workspace
|
||||
*/
|
||||
export function getNavGroupsForPath(
|
||||
pathname: string,
|
||||
t: TFunction
|
||||
): NavGroup[] | undefined {
|
||||
const workspace = getWorkspaceByPath(pathname)
|
||||
return workspace.getNavGroups?.(t)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if in specified workspace
|
||||
* @param pathname - Current route path
|
||||
* @param workspaceId - Workspace identifier
|
||||
* @returns Whether in specified workspace
|
||||
*/
|
||||
export function isInWorkspace(
|
||||
pathname: string,
|
||||
workspaceId: WorkspaceId
|
||||
): boolean {
|
||||
return getWorkspaceByPath(pathname).id === workspaceId
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered workspace configurations
|
||||
* @returns Array of workspace configurations
|
||||
*/
|
||||
export function getAllWorkspaces(): WorkspaceConfig[] {
|
||||
return workspaceRegistry
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { type LinkProps } from '@tanstack/react-router'
|
||||
|
||||
/**
|
||||
* Workspace type
|
||||
* Used for top switcher to display different workspaces
|
||||
*/
|
||||
export type Workspace = {
|
||||
id: string
|
||||
name: string
|
||||
logo: React.ElementType
|
||||
plan: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Base navigation item type
|
||||
*/
|
||||
type BaseNavItem = {
|
||||
title: string
|
||||
badge?: string
|
||||
icon?: React.ElementType
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation link type - single link item
|
||||
*/
|
||||
export type NavLink = BaseNavItem & {
|
||||
url: LinkProps['to'] | (string & {})
|
||||
items?: never
|
||||
type?: never
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation collapsible type - collapsible navigation with sub-items
|
||||
*/
|
||||
export type NavCollapsible = BaseNavItem & {
|
||||
items: (BaseNavItem & { url: LinkProps['to'] | (string & {}) })[]
|
||||
url?: never
|
||||
type?: never
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic chat presets type - dynamically loaded chat preset list from API
|
||||
*/
|
||||
export type NavChatPresets = BaseNavItem & {
|
||||
type: 'chat-presets'
|
||||
url?: never
|
||||
items?: never
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation item union type
|
||||
*/
|
||||
export type NavItem = NavCollapsible | NavLink | NavChatPresets
|
||||
|
||||
/**
|
||||
* Navigation group type - a group of navigation items in sidebar
|
||||
*/
|
||||
export type NavGroup = {
|
||||
id?: string
|
||||
title: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar data type
|
||||
*/
|
||||
export type SidebarData = {
|
||||
workspaces: Workspace[]
|
||||
navGroups: NavGroup[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Top navigation link type
|
||||
*/
|
||||
export type TopNavLink = {
|
||||
title: string
|
||||
href: string
|
||||
isActive?: boolean
|
||||
disabled?: boolean
|
||||
external?: boolean
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { type Root, type Content, type Trigger } from '@radix-ui/react-popover'
|
||||
import { CircleQuestionMark } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
type LearnMoreProps = React.ComponentProps<typeof Root> & {
|
||||
contentProps?: React.ComponentProps<typeof Content>
|
||||
triggerProps?: React.ComponentProps<typeof Trigger>
|
||||
}
|
||||
|
||||
export function LearnMore({
|
||||
children,
|
||||
contentProps,
|
||||
triggerProps,
|
||||
...props
|
||||
}: LearnMoreProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Popover {...props}>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
{...triggerProps}
|
||||
className={cn('size-5 rounded-full', triggerProps?.className)}
|
||||
>
|
||||
<Button variant='outline' size='icon'>
|
||||
<span className='sr-only'>{t('Learn more')}</span>
|
||||
<CircleQuestionMark className='size-4 [&>circle]:hidden' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='top'
|
||||
align='start'
|
||||
{...contentProps}
|
||||
className={cn('text-muted-foreground text-sm', contentProps?.className)}
|
||||
>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LoadingStateProps {
|
||||
className?: string
|
||||
message?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: 'size-4',
|
||||
md: 'size-6',
|
||||
lg: 'size-8',
|
||||
} as const
|
||||
|
||||
export function LoadingState(props: LoadingStateProps) {
|
||||
const { t } = useTranslation()
|
||||
const iconSize = sizeMap[props.size ?? 'md']
|
||||
|
||||
if (props.inline) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-2', props.className)}>
|
||||
<Loader2 className={cn(iconSize, 'animate-spin')} />
|
||||
{props.message != null && (
|
||||
<span className='text-muted-foreground text-sm'>{props.message}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-[200px] flex-col items-center justify-center gap-3',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className='animate-spin'>
|
||||
<Loader2 className={iconSize} />
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{props.message ?? t('Loading...')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
type LongTextProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
export function LongText({
|
||||
children,
|
||||
className = '',
|
||||
contentClassName = '',
|
||||
}: LongTextProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [isOverflown, setIsOverflown] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (checkOverflow(ref.current)) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsOverflown(true)
|
||||
return
|
||||
}
|
||||
|
||||
setIsOverflown(false)
|
||||
}, [])
|
||||
|
||||
if (!isOverflown)
|
||||
return (
|
||||
<div ref={ref} className={cn('truncate', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='hidden sm:block'>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div ref={ref} className={cn('truncate', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className={contentClassName}>{children}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className='sm:hidden'>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div ref={ref} className={cn('truncate', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={cn('w-fit', contentClassName)}>
|
||||
<p>{children}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const checkOverflow = (textContainer: HTMLDivElement | null) => {
|
||||
if (textContainer) {
|
||||
return (
|
||||
textContainer.offsetHeight < textContainer.scrollHeight ||
|
||||
textContainer.offsetWidth < textContainer.scrollWidth
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
|
||||
interface MaskedValueDisplayProps {
|
||||
/** 弹层内标题,如 "Full API Key" / "Full Code" */
|
||||
label: string
|
||||
/** 完整值,在 Popover 内完整展示 */
|
||||
fullValue: string
|
||||
/** 表格内显示的脱敏值 */
|
||||
maskedValue: string
|
||||
/** 复制按钮的 tooltip */
|
||||
copyTooltip: string
|
||||
/** 复制按钮的 aria-label */
|
||||
copyAriaLabel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于在表格中展示脱敏密钥/兑换码:点击显示完整内容(文本块完整显示,非 Input),支持一键复制。
|
||||
*/
|
||||
export function MaskedValueDisplay(props: MaskedValueDisplayProps) {
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='ghost' size='sm' className='h-7 font-mono'>
|
||||
{props.maskedValue}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-auto max-w-[min(90vw,28rem)]'
|
||||
align='start'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-muted-foreground text-xs'>{props.label}</p>
|
||||
<pre
|
||||
className='bg-muted/50 max-h-[50vh] overflow-auto rounded-md border px-3 py-2 font-mono text-xs leading-relaxed break-all whitespace-pre-wrap'
|
||||
style={{ wordBreak: 'break-all' }}
|
||||
>
|
||||
{props.fullValue}
|
||||
</pre>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<CopyButton
|
||||
value={props.fullValue}
|
||||
className='size-7'
|
||||
iconClassName='size-3.5'
|
||||
tooltip={props.copyTooltip}
|
||||
aria-label={props.copyAriaLabel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react'
|
||||
import { ChevronsUpDown, Check, CpuIcon, LayersIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@/components/ui/drawer'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
interface ModelOption {
|
||||
label: string
|
||||
value: string
|
||||
category?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface GroupOption {
|
||||
label: string
|
||||
value: string
|
||||
ratio?: number
|
||||
desc?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: string
|
||||
models: ModelOption[]
|
||||
onModelChange: (value: string) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface GroupSelectorProps {
|
||||
selectedGroup: string
|
||||
groups: GroupOption[]
|
||||
onGroupChange: (value: string) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ModelTriggerButton = React.forwardRef<
|
||||
React.ComponentRef<typeof Button>,
|
||||
React.ComponentPropsWithoutRef<typeof Button> & {
|
||||
currentLabel: string
|
||||
triggerClassName?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
>(({ currentLabel, triggerClassName, isDisabled, ...props }, ref) => (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
size='sm'
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'flex h-8 items-center gap-2 rounded-full border px-3 font-medium',
|
||||
'justify-center p-0 sm:w-auto sm:justify-start sm:px-3',
|
||||
'w-8',
|
||||
'bg-background text-foreground',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:!ring-0 focus:!outline-none',
|
||||
'shadow-none',
|
||||
triggerClassName
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CpuIcon className='text-muted-foreground block size-4 sm:hidden' />
|
||||
<span className='text-muted-foreground sm:text-foreground hidden truncate text-xs sm:block'>
|
||||
{currentLabel}
|
||||
</span>
|
||||
<ChevronsUpDown className='text-muted-foreground hidden h-4 w-4 opacity-50 sm:block' />
|
||||
</Button>
|
||||
))
|
||||
|
||||
ModelTriggerButton.displayName = 'ModelTriggerButton'
|
||||
|
||||
const GroupTriggerButton = React.forwardRef<
|
||||
React.ComponentRef<typeof Button>,
|
||||
React.ComponentPropsWithoutRef<typeof Button> & {
|
||||
currentLabel: string
|
||||
triggerClassName?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
>(({ currentLabel, triggerClassName, isDisabled, ...props }, ref) => (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
size='sm'
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'flex h-8 items-center gap-2 rounded-full border px-3 font-medium',
|
||||
'justify-center p-0 sm:w-auto sm:justify-start sm:px-3',
|
||||
'w-8',
|
||||
'bg-background text-foreground',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:!ring-0 focus:!outline-none',
|
||||
'shadow-none',
|
||||
triggerClassName
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<LayersIcon className='text-muted-foreground block size-4 sm:hidden' />
|
||||
<span className='text-muted-foreground sm:text-foreground hidden truncate text-xs sm:block'>
|
||||
{currentLabel}
|
||||
</span>
|
||||
<ChevronsUpDown className='text-muted-foreground hidden h-4 w-4 opacity-50 sm:block' />
|
||||
</Button>
|
||||
))
|
||||
|
||||
GroupTriggerButton.displayName = 'GroupTriggerButton'
|
||||
|
||||
/**
|
||||
* Model Selector Component
|
||||
* Styled following Scira's form-component design patterns
|
||||
*/
|
||||
export const ModelSelector: React.FC<ModelSelectorProps> = React.memo(
|
||||
({ selectedModel, models, onModelChange, className, disabled = false }) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const currentModel = useMemo(
|
||||
() => models.find((m) => m.value === selectedModel),
|
||||
[models, selectedModel]
|
||||
)
|
||||
|
||||
// Group models by category
|
||||
const groupedModels = useMemo(
|
||||
() =>
|
||||
models.reduce(
|
||||
(acc, model) => {
|
||||
const category = model.category || t('Other')
|
||||
if (!acc[category]) {
|
||||
acc[category] = []
|
||||
}
|
||||
acc[category].push(model)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, ModelOption[]>
|
||||
),
|
||||
[models, t]
|
||||
)
|
||||
|
||||
// Filter models by search query
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!searchQuery.trim()) return groupedModels
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
const filtered: Record<string, ModelOption[]> = {}
|
||||
|
||||
Object.entries(groupedModels).forEach(([category, categoryModels]) => {
|
||||
const matches = categoryModels.filter(
|
||||
(m) =>
|
||||
m.label.toLowerCase().includes(query) ||
|
||||
m.value.toLowerCase().includes(query) ||
|
||||
m.description?.toLowerCase().includes(query)
|
||||
)
|
||||
if (matches.length > 0) {
|
||||
filtered[category] = matches
|
||||
}
|
||||
})
|
||||
|
||||
return filtered
|
||||
}, [groupedModels, searchQuery])
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
(value: string) => {
|
||||
onModelChange(value)
|
||||
setOpen(false)
|
||||
setSearchQuery('')
|
||||
},
|
||||
[onModelChange]
|
||||
)
|
||||
|
||||
// Shared command content
|
||||
const renderModelCommandContent = () => (
|
||||
<Command
|
||||
className={cn(
|
||||
isMobile
|
||||
? 'h-full flex-1 rounded-lg border-0 bg-transparent'
|
||||
: 'rounded-lg'
|
||||
)}
|
||||
filter={() => 1}
|
||||
shouldFilter={false}
|
||||
>
|
||||
{!isMobile && (
|
||||
<CommandInput
|
||||
placeholder={t('Search models...')}
|
||||
className='h-9'
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
/>
|
||||
)}
|
||||
<CommandEmpty>{t('No model found.')}</CommandEmpty>
|
||||
<CommandList
|
||||
className={isMobile ? '!max-h-full flex-1 p-2' : 'max-h-[300px]'}
|
||||
>
|
||||
{Object.keys(filteredModels).length === 0 ? (
|
||||
<div className='text-muted-foreground px-3 py-6 text-xs'>
|
||||
{t('No model found.')}
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(filteredModels).map(
|
||||
([category, categoryModels], categoryIndex) => (
|
||||
<CommandGroup key={category}>
|
||||
{categoryIndex > 0 && (
|
||||
<div className='border-border my-1 border-t' />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground px-2 py-1 font-medium',
|
||||
isMobile ? 'text-xs' : 'text-[10px]'
|
||||
)}
|
||||
>
|
||||
{t('{{category}} Models', { category })}
|
||||
</div>
|
||||
{categoryModels.map((model) => (
|
||||
<CommandItem
|
||||
key={model.value}
|
||||
value={model.value}
|
||||
onSelect={handleModelChange}
|
||||
className={cn(
|
||||
'mb-0.5 flex items-center justify-between rounded-lg px-2 py-1.5 text-xs',
|
||||
'transition-all duration-200',
|
||||
'hover:bg-accent',
|
||||
'data-[selected=true]:bg-accent'
|
||||
)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-1'>
|
||||
<div
|
||||
className={cn(
|
||||
'truncate font-medium',
|
||||
isMobile ? 'text-sm' : 'text-[11px]'
|
||||
)}
|
||||
>
|
||||
<span className='inline'>{model.label}</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
'h-4 w-4 flex-shrink-0',
|
||||
selectedModel === model.value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<ModelTriggerButton
|
||||
currentLabel={currentModel?.label || t('Model')}
|
||||
triggerClassName={className}
|
||||
isDisabled={disabled}
|
||||
aria-expanded={open}
|
||||
/>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className='flex max-h-[80vh] min-h-[60vh] flex-col'>
|
||||
<DrawerHeader className='flex-shrink-0 pb-4'>
|
||||
<DrawerTitle className='flex items-center gap-2 text-left text-lg font-medium'>
|
||||
{t('Select Model')}
|
||||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className='flex min-h-0 flex-1 flex-col'>
|
||||
{renderModelCommandContent()}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<ModelTriggerButton
|
||||
currentLabel={currentModel?.label || t('Model')}
|
||||
triggerClassName={className}
|
||||
isDisabled={disabled}
|
||||
aria-expanded={open}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='bg-popover z-40 w-[90vw] max-w-[20em] rounded-lg border p-0 !shadow-none sm:w-[20em]'
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
>
|
||||
{renderModelCommandContent()}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ModelSelector.displayName = 'ModelSelector'
|
||||
|
||||
/**
|
||||
* Group Selector Component
|
||||
* Styled following Scira's form-component design patterns
|
||||
*/
|
||||
export const GroupSelector: React.FC<GroupSelectorProps> = React.memo(
|
||||
({ selectedGroup, groups, onGroupChange, className, disabled = false }) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const currentGroup = useMemo(
|
||||
() => groups.find((g) => g.value === selectedGroup),
|
||||
[groups, selectedGroup]
|
||||
)
|
||||
|
||||
const handleGroupChange = useCallback(
|
||||
(value: string) => {
|
||||
onGroupChange(value)
|
||||
setOpen(false)
|
||||
},
|
||||
[onGroupChange]
|
||||
)
|
||||
|
||||
// Shared command content
|
||||
const renderGroupCommandContent = () => (
|
||||
<Command
|
||||
className={cn(
|
||||
isMobile
|
||||
? 'h-full flex-1 rounded-lg border-0 bg-transparent'
|
||||
: 'rounded-lg'
|
||||
)}
|
||||
filter={(value, search) => {
|
||||
const group = groups.find((g) => g.value === value)
|
||||
if (!group || !search) return 1
|
||||
|
||||
const searchTerm = search.toLowerCase()
|
||||
const searchableFields = [
|
||||
group.label,
|
||||
group.description || '',
|
||||
group.value,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return searchableFields.includes(searchTerm) ? 1 : 0
|
||||
}}
|
||||
>
|
||||
<CommandInput placeholder={t('Search groups...')} className='h-9' />
|
||||
<CommandEmpty>{t('No group found.')}</CommandEmpty>
|
||||
<CommandList
|
||||
className={isMobile ? '!max-h-full flex-1 p-2' : 'max-h-[240px]'}
|
||||
>
|
||||
<CommandGroup>
|
||||
<div className='text-muted-foreground px-2 py-1 text-[10px] font-medium'>
|
||||
{t('Model Group')}
|
||||
</div>
|
||||
{groups.map((group) => (
|
||||
<CommandItem
|
||||
key={group.value}
|
||||
value={group.value}
|
||||
onSelect={handleGroupChange}
|
||||
className={cn(
|
||||
'mb-0.5 flex items-center justify-between rounded-lg px-2 py-2 text-xs',
|
||||
'transition-all duration-200',
|
||||
'hover:bg-accent',
|
||||
'data-[selected=true]:bg-accent'
|
||||
)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2 pr-4'>
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<span className='text-foreground truncate text-[11px] font-medium'>
|
||||
{group.label}
|
||||
</span>
|
||||
{(group.desc || group.description) && (
|
||||
<div className='text-muted-foreground truncate text-[9px] leading-tight'>
|
||||
{group.desc || group.description}
|
||||
{group.ratio && (
|
||||
<>
|
||||
{' · '}
|
||||
{t('Ratio: {{value}}', { value: group.ratio })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto h-4 w-4',
|
||||
selectedGroup === group.value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<GroupTriggerButton
|
||||
currentLabel={currentGroup?.label || t('Group')}
|
||||
triggerClassName={className}
|
||||
isDisabled={disabled}
|
||||
aria-expanded={open}
|
||||
/>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className='max-h-[80vh]'>
|
||||
<DrawerHeader className='pb-4 text-left'>
|
||||
<DrawerTitle>{t('Choose Group')}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className='max-h-[calc(80vh-100px)] overflow-y-auto px-4 pb-6'>
|
||||
<div className='space-y-2'>
|
||||
{groups.map((group) => (
|
||||
<Button
|
||||
key={group.value}
|
||||
variant='outline'
|
||||
onClick={() => handleGroupChange(group.value)}
|
||||
className={cn(
|
||||
'flex h-auto w-full items-center justify-between rounded-lg p-4 text-left whitespace-normal',
|
||||
'border-border hover:bg-accent',
|
||||
selectedGroup === group.value
|
||||
? 'bg-accent border-primary/20'
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-3'>
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<span className='text-foreground text-sm font-medium'>
|
||||
{group.label}
|
||||
</span>
|
||||
{(group.desc || group.description) && (
|
||||
<div className='text-muted-foreground mt-0.5 text-xs'>
|
||||
{group.desc || group.description}
|
||||
{group.ratio && (
|
||||
<>
|
||||
{' · '}
|
||||
{t('Ratio: {{value}}', {
|
||||
value: group.ratio,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-3 h-5 w-5 shrink-0',
|
||||
selectedGroup === group.value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<GroupTriggerButton
|
||||
currentLabel={currentGroup?.label || t('Group')}
|
||||
triggerClassName={className}
|
||||
isDisabled={disabled}
|
||||
aria-expanded={open}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='bg-popover z-50 w-[90vw] max-w-[14em] rounded-lg border p-0 !shadow-none sm:w-[14em]'
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
>
|
||||
{renderGroupCommandContent()}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
GroupSelector.displayName = 'GroupSelector'
|
||||
|
||||
// Export combined selector component
|
||||
export interface ModelGroupSelectorProps {
|
||||
// Model props
|
||||
selectedModel: string
|
||||
models: ModelOption[]
|
||||
onModelChange: (value: string) => void
|
||||
// Group props
|
||||
selectedGroup: string
|
||||
groups: GroupOption[]
|
||||
onGroupChange: (value: string) => void
|
||||
// Common props
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined Model and Group Selector Component
|
||||
* Provides both model and group selection in a unified interface
|
||||
*/
|
||||
export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
|
||||
selectedModel,
|
||||
models,
|
||||
onModelChange,
|
||||
selectedGroup,
|
||||
groups,
|
||||
onGroupChange,
|
||||
className,
|
||||
disabled = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<GroupSelector
|
||||
selectedGroup={selectedGroup}
|
||||
groups={groups}
|
||||
onGroupChange={onGroupChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
models={models}
|
||||
onModelChange={onModelChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import * as React from 'react'
|
||||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandGroup, CommandItem } from '@/components/ui/command'
|
||||
|
||||
export type Option = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
options: Option[]
|
||||
selected: string[]
|
||||
onChange: (values: string[]) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MultiSelect({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
}: MultiSelectProps) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedPlaceholder = placeholder ?? t('Select items...')
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [inputValue, setInputValue] = React.useState('')
|
||||
|
||||
const handleUnselect = (value: string) => {
|
||||
onChange(selected.filter((s) => s !== value))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const input = inputRef.current
|
||||
if (input) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (input.value === '' && selected.length > 0) {
|
||||
onChange(selected.slice(0, -1))
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
input.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectables = options.filter(
|
||||
(option) => !selected.includes(option.value)
|
||||
)
|
||||
|
||||
return (
|
||||
<Command
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`overflow-visible bg-transparent ${className || ''}`}
|
||||
>
|
||||
<div className='group border-input ring-offset-background focus-within:ring-ring rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2'>
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{selected.map((value) => {
|
||||
const option = options.find((o) => o.value === value)
|
||||
return (
|
||||
<Badge key={value} variant='secondary'>
|
||||
{option?.label || value}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
aria-label='Remove'
|
||||
className='ml-1 size-auto rounded-full p-0'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleUnselect(value)
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={() => handleUnselect(value)}
|
||||
>
|
||||
<X
|
||||
className='text-muted-foreground hover:text-foreground h-3 w-3'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</Button>
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
<CommandPrimitive.Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
onBlur={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={selected.length === 0 ? resolvedPlaceholder : ''}
|
||||
className='placeholder:text-muted-foreground flex-1 bg-transparent outline-none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
{open && selectables.length > 0 ? (
|
||||
<div className='bg-popover text-popover-foreground animate-in absolute top-0 z-10 w-full rounded-md border shadow-md outline-none'>
|
||||
<CommandGroup className='h-full max-h-60 overflow-auto'>
|
||||
{selectables.map((option) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onSelect={() => {
|
||||
setInputValue('')
|
||||
onChange([...selected, option.value])
|
||||
}}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRouterState } from '@tanstack/react-router'
|
||||
import LoadingBar, { type LoadingBarRef } from 'react-top-loading-bar'
|
||||
|
||||
export function NavigationProgress() {
|
||||
const ref = useRef<LoadingBarRef>(null)
|
||||
const state = useRouterState()
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'pending') {
|
||||
ref.current?.continuousStart()
|
||||
} else {
|
||||
ref.current?.complete()
|
||||
}
|
||||
}, [state.status])
|
||||
|
||||
return (
|
||||
<LoadingBar
|
||||
color='var(--muted-foreground)'
|
||||
ref={ref}
|
||||
shadow={true}
|
||||
height={2}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Bell } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface NotificationButtonProps {
|
||||
unreadCount: number
|
||||
onClick: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification bell button with unread badge
|
||||
* Displays in the app header next to theme switch and profile dropdown
|
||||
*/
|
||||
export function NotificationButton({
|
||||
unreadCount,
|
||||
onClick,
|
||||
className,
|
||||
}: NotificationButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='relative'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={onClick}
|
||||
className={cn('h-9 w-9 rounded-full', className)}
|
||||
aria-label={t('Notifications')}
|
||||
>
|
||||
<Bell className='size-[1.2rem]' />
|
||||
</Button>
|
||||
|
||||
{unreadCount > 0 && (
|
||||
<Badge
|
||||
variant='destructive'
|
||||
className='absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-semibold tabular-nums'
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import { Bell, Megaphone } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getAnnouncementColorClass } from '@/lib/colors'
|
||||
import { formatDateTimeObject } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Markdown } from '@/components/ui/markdown'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
interface AnnouncementItem {
|
||||
type?: string
|
||||
content?: string
|
||||
extra?: string
|
||||
publishDate?: string | Date
|
||||
}
|
||||
|
||||
interface NotificationDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
activeTab: 'notice' | 'announcements'
|
||||
onTabChange: (tab: 'notice' | 'announcements') => void
|
||||
notice: string
|
||||
announcements: AnnouncementItem[]
|
||||
loading: boolean
|
||||
onCloseToday: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative time string from a date
|
||||
*/
|
||||
function getRelativeTime(publishDate: string | Date, t: TFunction): string {
|
||||
if (!publishDate) return ''
|
||||
|
||||
const now = new Date()
|
||||
const pubDate = new Date(publishDate)
|
||||
|
||||
// If invalid date, return original string
|
||||
if (isNaN(pubDate.getTime()))
|
||||
return typeof publishDate === 'string' ? publishDate : ''
|
||||
|
||||
const diffMs = now.getTime() - pubDate.getTime()
|
||||
const diffSeconds = Math.floor(diffMs / 1000)
|
||||
const diffMinutes = Math.floor(diffSeconds / 60)
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
const diffWeeks = Math.floor(diffDays / 7)
|
||||
const diffMonths = Math.floor(diffDays / 30)
|
||||
const diffYears = Math.floor(diffDays / 365)
|
||||
|
||||
// If future time, show specific date
|
||||
if (diffMs < 0) return formatDateTimeObject(pubDate)
|
||||
|
||||
// Return relative time based on difference
|
||||
if (diffSeconds < 60) return t('Just now')
|
||||
if (diffMinutes < 60)
|
||||
return diffMinutes === 1
|
||||
? t('1 minute ago')
|
||||
: t('{{count}} minutes ago', { count: diffMinutes })
|
||||
if (diffHours < 24)
|
||||
return diffHours === 1
|
||||
? t('1 hour ago')
|
||||
: t('{{count}} hours ago', { count: diffHours })
|
||||
if (diffDays < 7)
|
||||
return diffDays === 1
|
||||
? t('1 day ago')
|
||||
: t('{{count}} days ago', { count: diffDays })
|
||||
if (diffWeeks < 4)
|
||||
return diffWeeks === 1
|
||||
? t('1 week ago')
|
||||
: t('{{count}} weeks ago', { count: diffWeeks })
|
||||
if (diffMonths < 12)
|
||||
return diffMonths === 1
|
||||
? t('1 month ago')
|
||||
: t('{{count}} months ago', { count: diffMonths })
|
||||
if (diffYears < 2) return t('1 year ago')
|
||||
|
||||
// Over 2 years, show specific date
|
||||
return formatDateTimeObject(pubDate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Announcement status dot indicator
|
||||
*/
|
||||
function AnnouncementDot({ type }: { type?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1.5 inline-block h-2 w-2 shrink-0 rounded-full',
|
||||
getAnnouncementColorClass(type)
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state component
|
||||
*/
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||
<p className='text-muted-foreground text-sm'>{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notice tab content
|
||||
*/
|
||||
function NoticeContent({
|
||||
notice,
|
||||
loading,
|
||||
t,
|
||||
}: {
|
||||
notice: string
|
||||
loading: boolean
|
||||
t: TFunction
|
||||
}) {
|
||||
if (loading) {
|
||||
return <EmptyState message={t('Loading...')} />
|
||||
}
|
||||
|
||||
if (!notice) {
|
||||
return <EmptyState message={t('No announcements at this time')} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className='h-[50vh] pr-4'>
|
||||
<Markdown>{notice}</Markdown>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Announcements tab content
|
||||
*/
|
||||
function AnnouncementsContent({
|
||||
announcements,
|
||||
loading,
|
||||
t,
|
||||
}: {
|
||||
announcements: AnnouncementItem[]
|
||||
loading: boolean
|
||||
t: TFunction
|
||||
}) {
|
||||
if (loading) {
|
||||
return <EmptyState message={t('Loading...')} />
|
||||
}
|
||||
|
||||
if (announcements.length === 0) {
|
||||
return <EmptyState message={t('No system announcements')} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className='h-[50vh] pr-4'>
|
||||
<div className='space-y-0'>
|
||||
{announcements.map((item, idx) => {
|
||||
const publishDate = item.publishDate
|
||||
? new Date(item.publishDate)
|
||||
: null
|
||||
const relativeTime = publishDate
|
||||
? getRelativeTime(publishDate, t)
|
||||
: ''
|
||||
const absoluteTime = publishDate
|
||||
? formatDateTimeObject(publishDate)
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div key={idx}>
|
||||
<div className='py-3'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<AnnouncementDot type={item.type} />
|
||||
<div className='min-w-0 flex-1 space-y-2'>
|
||||
{/* Content */}
|
||||
<div className='text-sm'>
|
||||
<Markdown>{item.content || ''}</Markdown>
|
||||
</div>
|
||||
|
||||
{/* Extra info */}
|
||||
{item.extra && (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
<Markdown>{item.extra}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time */}
|
||||
{absoluteTime && (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{relativeTime && `${relativeTime} • `}
|
||||
{absoluteTime}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{idx < announcements.length - 1 && <Separator />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification dialog with Notice and Announcements tabs
|
||||
*/
|
||||
export function NotificationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
notice,
|
||||
announcements,
|
||||
loading,
|
||||
onCloseToday,
|
||||
}: NotificationDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] sm:max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('System Announcements')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={onTabChange as (value: string) => void}
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='notice' className='gap-1.5'>
|
||||
<Bell className='h-3.5 w-3.5' />
|
||||
{t('Notice')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='announcements' className='gap-1.5'>
|
||||
<Megaphone className='h-3.5 w-3.5' />
|
||||
{t('Timeline')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='notice' className='mt-4'>
|
||||
<NoticeContent notice={notice} loading={loading} t={t} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='announcements' className='mt-4'>
|
||||
<AnnouncementsContent
|
||||
announcements={announcements}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className='gap-2'>
|
||||
<Button variant='outline' onClick={onCloseToday}>
|
||||
{t('Close Today')}
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>{t('Close')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Outlet, useRouterState } from '@tanstack/react-router'
|
||||
import { motion, useReducedMotion, type Variants } from 'motion/react'
|
||||
import {
|
||||
CARD_ITEM_VARIANTS,
|
||||
CARD_STAGGER_VARIANTS,
|
||||
MOTION_TRANSITION,
|
||||
MOTION_VARIANTS,
|
||||
STAGGER_ITEM_VARIANTS,
|
||||
STAGGER_VARIANTS,
|
||||
TABLE_ROW_VARIANTS,
|
||||
TABLE_STAGGER_VARIANTS,
|
||||
} from '@/lib/motion'
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PageTransition(props: PageTransitionProps) {
|
||||
const shouldReduce = useReducedMotion()
|
||||
|
||||
if (shouldReduce) {
|
||||
return <div className={props.className}>{props.children}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={MOTION_VARIANTS.pageEnter.initial}
|
||||
animate={MOTION_VARIANTS.pageEnter.animate}
|
||||
transition={MOTION_TRANSITION.default}
|
||||
className={props.className}
|
||||
>
|
||||
{props.children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnimatedOutlet() {
|
||||
const shouldReduce = useReducedMotion()
|
||||
const routeKey = useRouterState({
|
||||
select: (s) => s.location.pathname,
|
||||
})
|
||||
|
||||
if (shouldReduce) {
|
||||
return (
|
||||
<div className='flex min-h-0 flex-1 flex-col'>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={routeKey}
|
||||
initial={MOTION_VARIANTS.pageEnter.initial}
|
||||
animate={MOTION_VARIANTS.pageEnter.animate}
|
||||
transition={MOTION_TRANSITION.fast}
|
||||
className='flex min-h-0 flex-1 flex-col'
|
||||
>
|
||||
<Outlet />
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StaggerContainerProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
variants?: Variants
|
||||
}
|
||||
|
||||
export function StaggerContainer(props: StaggerContainerProps) {
|
||||
const shouldReduce = useReducedMotion()
|
||||
|
||||
if (shouldReduce) {
|
||||
return <div className={props.className}>{props.children}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={props.variants ?? STAGGER_VARIANTS}
|
||||
initial='initial'
|
||||
animate='animate'
|
||||
className={props.className}
|
||||
>
|
||||
{props.children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StaggerItemProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
variants?: Variants
|
||||
}
|
||||
|
||||
export function StaggerItem(props: StaggerItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={props.variants ?? STAGGER_ITEM_VARIANTS}
|
||||
className={props.className}
|
||||
>
|
||||
{props.children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableStaggerContainer(props: StaggerContainerProps) {
|
||||
const shouldReduce = useReducedMotion()
|
||||
|
||||
if (shouldReduce) {
|
||||
return <>{props.children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.tbody
|
||||
variants={TABLE_STAGGER_VARIANTS}
|
||||
initial='initial'
|
||||
animate='animate'
|
||||
className={props.className}
|
||||
>
|
||||
{props.children}
|
||||
</motion.tbody>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableStaggerRow(props: StaggerItemProps) {
|
||||
return (
|
||||
<motion.tr variants={TABLE_ROW_VARIANTS} className={props.className}>
|
||||
{props.children}
|
||||
</motion.tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardStaggerContainer(props: StaggerContainerProps) {
|
||||
const shouldReduce = useReducedMotion()
|
||||
|
||||
if (shouldReduce) {
|
||||
return <div className={props.className}>{props.children}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={CARD_STAGGER_VARIANTS}
|
||||
initial='initial'
|
||||
animate='animate'
|
||||
className={props.className}
|
||||
>
|
||||
{props.children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardStaggerItem(props: StaggerItemProps) {
|
||||
return (
|
||||
<motion.div variants={CARD_ITEM_VARIANTS} className={props.className}>
|
||||
{props.children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FadeInProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export function FadeIn(props: FadeInProps) {
|
||||
const shouldReduce = useReducedMotion()
|
||||
|
||||
if (shouldReduce) {
|
||||
return <div className={props.className}>{props.children}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={MOTION_VARIANTS.fadeIn.initial}
|
||||
animate={MOTION_VARIANTS.fadeIn.animate}
|
||||
transition={{
|
||||
...MOTION_TRANSITION.default,
|
||||
delay: props.delay,
|
||||
}}
|
||||
className={props.className}
|
||||
>
|
||||
{props.children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from './ui/button'
|
||||
import { Input } from './ui/input'
|
||||
|
||||
type PasswordInputProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
'type'
|
||||
> & {
|
||||
ref?: React.Ref<HTMLInputElement>
|
||||
}
|
||||
|
||||
export function PasswordInput({
|
||||
className,
|
||||
disabled,
|
||||
ref,
|
||||
...props
|
||||
}: PasswordInputProps) {
|
||||
const [showPassword, setShowPassword] = React.useState(false)
|
||||
|
||||
return (
|
||||
<div className={cn('relative rounded-md', className)}>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
disabled={disabled}
|
||||
className='text-muted-foreground absolute end-1 top-1/2 h-6 w-6 -translate-y-1/2 rounded-md'
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
aria-label='Toggle password visibility'
|
||||
>
|
||||
{showPassword ? (
|
||||
<Eye size={18} aria-hidden='true' />
|
||||
) : (
|
||||
<EyeOff size={18} aria-hidden='true' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { User, Wallet, LogOut, Settings } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import useDialogState from '@/hooks/use-dialog'
|
||||
import { useUserDisplay } from '@/hooks/use-user-display'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
} from '@/components/ui/sheet'
|
||||
import { SignOutDialog } from '@/components/sign-out-dialog'
|
||||
|
||||
export function ProfileDropdown() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useDialogState()
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const user = useAuthStore((state) => state.auth.user)
|
||||
const { displayName, initials, roleLabel } = useUserDisplay(user)
|
||||
const isSuperAdmin = user?.role === ROLE.SUPER_ADMIN
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant='ghost' className='relative h-9 w-9 rounded-full p-0'>
|
||||
<Avatar className='h-9 w-9'>
|
||||
<AvatarImage src='/avatars/01.png' alt={`@${displayName}`} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side='right'
|
||||
className='flex w-full flex-col p-0 sm:max-w-sm'
|
||||
>
|
||||
<SheetHeader className='border-b p-4'>
|
||||
<SheetTitle className='text-left'>{t('User Menu')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex flex-1 flex-col overflow-y-auto'>
|
||||
{/* User info section */}
|
||||
<div className='border-b p-2.5 pb-6.5'>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
<Avatar className='size-9'>
|
||||
<AvatarImage src='/avatars/01.png' alt={`@${displayName}`} />
|
||||
<AvatarFallback className='text-xs'>
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='flex flex-1 flex-col gap-0.5 overflow-hidden'>
|
||||
<p className='text-foreground truncate text-sm font-medium'>
|
||||
{displayName}
|
||||
</p>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{roleLabel}
|
||||
</span>
|
||||
{user?.group && (
|
||||
<>
|
||||
<span className='text-muted-foreground text-xs'>·</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{String(user.group)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation links */}
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
to='/profile'
|
||||
className='text-primary/60 hover:text-primary/80 flex items-center gap-2.5 border-b p-2.5 transition-colors'
|
||||
>
|
||||
<User className='size-4' />
|
||||
{t('Profile')}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
to='/wallet'
|
||||
className='text-primary/60 hover:text-primary/80 flex items-center gap-2.5 border-b p-2.5 transition-colors'
|
||||
>
|
||||
<Wallet className='size-4' />
|
||||
{t('Wallet')}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
|
||||
{/* System Settings - only for super admin */}
|
||||
{isSuperAdmin && (
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
to='/system-settings/general'
|
||||
search={{ section: 'system-info' }}
|
||||
className='text-primary/60 hover:text-primary/80 flex items-center gap-2.5 border-b p-2.5 transition-colors'
|
||||
>
|
||||
<Settings className='size-4' />
|
||||
{t('System Settings')}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
)}
|
||||
|
||||
{/* Sign out */}
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
setSheetOpen(false)
|
||||
setOpen(true)
|
||||
}}
|
||||
className='text-destructive hover:text-destructive/80 h-auto w-full justify-start gap-2.5 p-2.5 hover:bg-transparent'
|
||||
>
|
||||
<LogOut className='size-4' />
|
||||
{t('Sign out')}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<SignOutDialog open={!!open} onOpenChange={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearch } from '@/context/search-provider'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
type SearchProps = {
|
||||
className?: string
|
||||
type?: React.HTMLInputTypeAttribute
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function Search({ className = '', placeholder }: SearchProps) {
|
||||
const { t } = useTranslation()
|
||||
const { setOpen } = useSearch()
|
||||
const resolvedPlaceholder = placeholder ?? t('Search')
|
||||
return (
|
||||
<Button
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'bg-muted/25 group text-muted-foreground hover:bg-accent relative h-8 w-full flex-1 justify-start rounded-md text-sm font-normal shadow-none sm:w-40 sm:pe-12 md:flex-none lg:w-52 xl:w-64',
|
||||
className
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={resolvedPlaceholder}
|
||||
>
|
||||
<SearchIcon
|
||||
aria-hidden='true'
|
||||
className='absolute start-1.5 top-1/2 -translate-y-1/2'
|
||||
size={16}
|
||||
/>
|
||||
<span className='ms-4'>{resolvedPlaceholder}</span>
|
||||
<kbd className='bg-muted group-hover:bg-accent pointer-events-none absolute end-[0.3rem] top-[0.3rem] hidden h-5 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 select-none sm:flex'>
|
||||
<span className='text-xs'>⌘</span>
|
||||
{t('K')}
|
||||
</kbd>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { Loader } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FormControl } from '@/components/ui/form'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
type SelectDropdownProps = {
|
||||
onValueChange?: (value: string) => void
|
||||
defaultValue: string | undefined
|
||||
placeholder?: string
|
||||
isPending?: boolean
|
||||
items: { label: string; value: string }[] | undefined
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
isControlled?: boolean
|
||||
}
|
||||
|
||||
export function SelectDropdown({
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
isPending,
|
||||
items,
|
||||
placeholder,
|
||||
disabled,
|
||||
className = '',
|
||||
isControlled = false,
|
||||
}: SelectDropdownProps) {
|
||||
const { t } = useTranslation()
|
||||
const placeholderText = placeholder ?? t('Select')
|
||||
const defaultState = isControlled
|
||||
? { value: defaultValue, onValueChange }
|
||||
: { defaultValue, onValueChange }
|
||||
return (
|
||||
<Select {...defaultState}>
|
||||
<FormControl>
|
||||
<SelectTrigger disabled={disabled} className={cn(className)}>
|
||||
<SelectValue placeholder={placeholderText} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{isPending ? (
|
||||
<SelectItem disabled value='loading' className='h-14'>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<Loader className='h-5 w-5 animate-spin' />
|
||||
{' '}
|
||||
{t('Loading...')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
) : (
|
||||
items?.map(({ label, value }) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user