Compare commits

...
53 Commits
Author SHA1 Message Date
DenozordecandCursor 34a29ad337 ci(gitea): reuse HEAD tag when retrying a failed release
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 58s
CI / go (push) Successful in 1m6s
CI / bird2 (push) Successful in 14s
CI / release (push) Failing after 4m0s
При перезапуске job после уже созданного тега semantic-release не пишет .release-version — версию берём из v* на HEAD и публикуем образы.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 16:30:41 +07:00
DenozordecandCursor b04b27c215 refactor(web): align Frame KPI settings chrome and a11y
CI / changes (push) Successful in 5s
CI / openapi (push) Successful in 39s
CI / web (push) Successful in 55s
CI / go (push) Successful in 2m40s
CI / bird2 (push) Successful in 16s
CI / commitlint (push) Skipped
CI / release (push) Successful in 18s
Выровнять KPI на IconTile, вынести settings из settings-7, Sheet/AlertDialog и skip-link в общем chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 15:36:54 +07:00
DenozordecandCursor 6a25a3d137 fix(web): parse string readiness checks on monitoring System tab
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 54s
CI / release (push) Successful in 3m59s
Исправляет ложные «Ошибки проверок»: GET /v1/ready отдаёт строки ok/memory, а не boolean. Уплотнён ReUI Frame/donut layout на вкладке Система.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 13:41:58 +07:00
DenozordecandCursor 32d9dc6acb feat(directories): edit communities and DoH via row actions menu
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 36s
CI / web (push) Successful in 55s
CI / release (push) Successful in 4m28s
В data-grid справочников добавлено меню «⋯» с пунктом «Редактировать»
и FormDrawer на PATCH /v1/communities/{id} и /v1/doh-profiles/{id}.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 12:42:01 +07:00
DenozordecandCursor fc4f234cc5 feat(directories): add create dialogs for communities and DoH profiles
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 41s
CI / web (push) Successful in 1m13s
CI / release (push) Successful in 4m35s
На странице Справочники добавлены кнопки «Добавить» и FormDrawer
для POST /v1/communities и /v1/doh-profiles. Доступ ограничен
bgp:directories:write (sessionCanWriteDirectories).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 12:24:10 +07:00
Denozordec 821f342476 feat(modules): enhance ModuleKpiCards and add update functionality for modules
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 51s
CI / web (push) Successful in 1m26s
CI / release (push) Successful in 4m26s
Updated the ModuleKpiCards component to improve the display of KPI metrics by introducing a new entries count prop and simplifying the logic for displaying community and DoH information. Added a new mutation hook for updating modules, which includes success and error handling with toast notifications. Enhanced the ModuleDetailComponent to support editing modules with a new dialog and integrated the entries count into the KPI display.

Additionally, introduced a new helper function for generating short labels for DoH profiles, improving the overall user experience in module management.
2026-08-12 11:32:44 +07:00
Denozordec 4634aac2d1 feat(network): add delete functionality for BGP peers with confirmation dialog
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 44s
CI / web (push) Successful in 1m7s
CI / go (push) Successful in 2m36s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m4s
Implemented a delete action for BGP peers in the NetworkPeersCard component. Added a confirmation dialog to ensure user intent before deletion, enhancing the user experience and preventing accidental removals. Updated the component's state management to handle the delete target and integrated the delete mutation for backend interaction.
2026-08-07 16:16:38 +07:00
Denozordec 7953ae6e78 chore(reui): update documentation and component references to reflect new 20 building blocks
Updated the ReUI documentation to reflect the addition of three new components, bringing the total to 20. Adjusted descriptions, examples, and rules across various files to ensure consistency with the updated component list. Enhanced the API references and installation instructions to improve clarity for users.

This change includes updates to the SKILL.md, cli.md, components.md, registry.md, and workflow.md files, ensuring all references are accurate and up-to-date.
2026-08-07 15:44:38 +07:00
Denozordec d14550cda1 refactor(lookup): replace Alert components with LookupStatusBanner in LookupWizard
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 46s
CI / web (push) Successful in 1m12s
CI / release (push) Successful in 4m11s
Updated the LookupWizard component to enhance readability and maintainability by replacing Alert components with a new LookupStatusBanner component. This change simplifies the status display logic for matched and unmatched results, improving the user experience during the lookup process.
2026-07-31 15:53:10 +07:00
Denozordec 632adaa63f refactor(lookup): simplify layout by removing Frame components
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 46s
CI / web (push) Successful in 1m23s
CI / release (push) Successful in 4m16s
Refactored the LookupAddStep, LookupMatchesGrid, LookupSearchForm, and LookupWizard components to eliminate Frame components, replacing them with simpler div structures for improved readability and maintainability. Updated comments to reflect the new layout and functionality, aligning with the wizard-2 design pattern.
2026-07-31 15:21:16 +07:00
Denozordec cc80858952 refactor(lookup): remove LookupSummaryKpi component and update LookupWizard
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 47s
CI / web (push) Successful in 1m8s
CI / release (push) Successful in 4m19s
Deleted the LookupSummaryKpi component to streamline the lookup functionality. Updated the LookupWizard component to remove references to the deleted component and adjusted the layout to incorporate FrameFooter for improved user interaction. Modified API type documentation to reflect changes in the lookup membership wizard.
2026-07-31 13:42:02 +07:00
Denozordec b8dbf15254 feat(api): update types to support CIDR in lookup queries
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 54s
CI / release (push) Successful in 3m56s
Enhanced the API type definitions to include CIDR support alongside IP and domain in the lookup functionality. Updated relevant comments and descriptions to reflect the new capabilities, ensuring clarity in the API documentation.
2026-07-31 13:24:52 +07:00
DenozordecandCursor f45b2bed90 feat(lookup): enhance lookup functionality to support CIDR queries
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Failing after 40s
CI / web (push) Successful in 56s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 14s
CI / release (push) Skipped
Updated the lookup system to allow for CIDR queries in addition to IP and domain searches. This includes modifications to the API, frontend components, and documentation to reflect the new capabilities. The LookupSearchForm and LookupComponent were adjusted to accommodate CIDR input, and relevant tests were added to ensure functionality.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 13:20:18 +07:00
DenozordecandCursor b13c233679 feat(analytics): remove OperationsAnalyticsCard and update imports
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 44s
CI / web (push) Successful in 1m27s
CI / release (push) Successful in 4m25s
Deleted the OperationsAnalyticsCard component to streamline analytics functionality. Updated the analytics index file to reflect this change by removing the export for OperationsAnalyticsCard. Adjusted the QuickActionGrid component to enhance user interaction with new keyboard handling and badge functionality.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 13:07:26 +07:00
DenozordecandCursor 37f28dfcfd chore(tsconfig): remove baseUrl from TypeScript configuration files
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 57s
CI / web (push) Successful in 55s
CI / go (push) Successful in 1m14s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 4m7s
Removed the "baseUrl" property from tsconfig.base.json, apps/web/tsconfig.json, and packages/ui/tsconfig.json to streamline path resolution. Updated check-openapi-gen.sh to use 'sh' instead of 'bash' for improved compatibility and adjusted the script's error handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 12:44:31 +07:00
DenozordecandCursor 6c6e76fca3 feat(ops): protect metrics, rate-limit auth, agent secret timing, e2e smoke
CI / changes (push) Successful in 7s
CI / openapi (push) Failing after 40s
CI / web (push) Successful in 56s
CI / commitlint (push) Skipped
CI / go (push) Failing after 34s
CI / bird2 (push) Skipped
CI / release (push) Skipped
Bearer для /metrics (EVOBGP_METRICS_TOKEN); rate limit /v1/auth/config; constant-time agent secret; OTel stub; Playwright smoke; HTTP_PROXY note; checklist обновлён.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 12:29:00 +07:00
DenozordecandCursor 26f5172f88 test(web): add Vitest CI, ADRs, and PR Definition of Done
FE unit-тесты селекторов/api-client в CI; ADR по durable jobs, OpenAPI codegen и legacy Svelte; PR template WEB-19; процесс KPI cross-app sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 12:27:13 +07:00
DenozordecandCursor 8fe74c1d3b feat(jobs): add durable PG queue reclaim, slog, and richer metrics
JSON slog в ключевых пакетах; Prometheus path_group, job_audit_depth, upstream breaker; job_audit ClaimQueued/ReclaimStaleRunning + Adopt loop для HA после рестарта.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 12:26:18 +07:00
DenozordecandCursor 13e3d21ce2 feat(web): add OpenAPI TypeScript codegen and CI freshness check
Генерируем api.gen.ts из docs/openapi.yaml (openapi-typescript), добавляем scripts/check-openapi-gen.sh в CI. Закрываем решение по X-Tenant-Id как unimplemented; обновляем docs/README под React/ReUI и apps/web.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 12:19:02 +07:00
DenozordecandCursor a0cfbcdab1 chore(web): prune unused ReUI demo blocks and remove legacy Svelte
Оставлен только settings-7 (используется settings tabs). Удалён web-legacy-svelte; обновлены AGENTS.md и WEB-22.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 12:17:07 +07:00
DenozordecandCursor c3369059af feat(web): align ops screens with ReUI PRO kit and Frame surface
OpsDashboard+afterKpi на dashboard; FrameDataGrid вместо DataGridCard; KpiStatGrid вместо SectionCards; SettingsShell без Separator. Preview: dashboard-1, stats-12, data-grid-filtering-2, settings-16.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 12:15:59 +07:00
DenozordecandCursor 53ce80c9ff fix(pipeline): harden upstream resilience and production shutdown
DoH через DoWithRetry; CDN preview через UpstreamHTTPDo; частичный fail CDN (EVOBGP_CDN_PARTIAL_OK); безопасный доступ к Job.Meta; drain jobs при SIGTERM; ValidateProductionEnforce при EVOBGP_PRODUCTION=1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 12:11:52 +07:00
Denozordec 1e04e91dd8 feat(network): add delete functionality for speakers in network speakers card
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / web (push) Successful in 1m6s
CI / release (push) Successful in 4m12s
Implemented a delete feature for speakers within the NetworkSpeakersCard component. This includes a confirmation dialog for deletion and integration with the delete speaker mutation. Updated the table columns to include an action button for deleting speakers, enhancing the user experience for managing network speakers.
2026-07-31 03:19:54 +07:00
Denozordec 5fbe9c9b56 feat(network): implement peer discovery features and UI integration
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 52s
CI / go (push) Successful in 2m22s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m36s
Added functionality for peer discovery, including new API endpoints for listing, approving, and rejecting discovered peers. Updated the network queries and settings to support peer discovery configurations. Enhanced the UI to display discovered peers and integrated related settings in the tenant settings component. Updated OpenAPI documentation to reflect the new endpoints and parameters. This improves the network management capabilities by allowing dynamic peer discovery and management.
2026-07-30 22:31:59 +07:00
Denozordec fd3a217cbe feat(api): enhance community retrieval with flexible ID handling
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Skipped
CI / go (push) Successful in 1m16s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 4m5s
Updated the GetCommunity function to accept both UUIDs and community titles for improved flexibility in community retrieval. Added error handling for invalid ID formats and adjusted related functions to ensure consistent behavior across memory and PostgreSQL storage. This change enhances the API's usability by allowing more intuitive community lookups.
2026-07-23 11:30:46 +07:00
Denozordec ff6efec4c5 feat(api): add endpoint to list community prefixes with pagination
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / web (push) Skipped
CI / openapi (push) Successful in 25s
CI / go (push) Successful in 1m4s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 4m17s
Introduced a new GET endpoint `/v1/communities/{id}/prefixes` to retrieve unique prefixes associated with a community, including pagination support via cursor and limit parameters. Updated OpenAPI documentation to reflect this addition. Implemented backend logic in both PostgreSQL and in-memory storage to handle the new functionality, ensuring proper authorization checks and response formatting.
2026-07-23 11:10:53 +07:00
DenozordecandCursor 738d2e2256 feat(httpapi): add local audit log with portal dual-write
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / web (push) Skipped
CI / openapi (push) Successful in 32s
CI / go (push) Successful in 1m23s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m56s
Локальный audit_log (миграции pg/sqlite), GET /v1/audit, запись на CRUD и async push в auth-portal (source_app=bgp).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 13:24:54 +07:00
Denozordec 2e3e1493f5 feat(auth): add sessionCanManageApiKeys function and update access control logic
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 53s
CI / go (push) Successful in 56s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m2s
Introduced the sessionCanManageApiKeys function to determine if a session can manage API keys based on role, permissions, and admin status. Updated the AccessComponent to utilize this new function for enabling/disabling API key management features. Enhanced documentation to reflect changes in API key management roles and permissions, including updates to the OpenAPI specification.
2026-07-21 03:18:59 +07:00
DenozordecandCursor fd2fd8298d chore(rules): add kpi-hybrid rule for stats-12 DNA
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Skipped
CI / go (push) Successful in 1m5s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 18s
Закрепить только hybrid KPI из kit (KpiStatGrid / row tiles); ссылка из reui-mcp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 02:36:10 +07:00
DenozordecandCursor df7cd99060 chore(security): ignore local mcp.json configs
CI / changes (push) Successful in 5s
CI / openapi (push) Skipped
CI / web (push) Skipped
CI / commitlint (push) Skipped
CI / go (push) Successful in 1m0s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m9s
.cursor/mcp.json и .mcp.json в gitignore; убраны из индекса; example без секрета.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 00:30:06 +07:00
Denozordec c273cea067 fix: update daemon PID and timestamps in configuration files
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 1m9s
CI / go (push) Successful in 1m6s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 23s
Adjusted the daemon PID and updated the startedAt timestamp in the .codegraph/daemon.pid file. Modified the mcp.json configuration to change the API URL and update the authorization header for improved security. This ensures the application uses the correct process ID and API endpoint for functionality.
2026-07-21 00:25:52 +07:00
DenozordecandCursor 1f969e6cac feat(web): align NavUser chrome and ReUI PRO kit
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 51s
CI / go (push) Successful in 55s
CI / bird2 (push) Canceled after 0s
CI / release (push) Canceled after 0s
Тема в NavUser вместо ModeToggle в header; общие kpi-cols для KPI/Quick Actions; WEB-01 → user-reui; skill 42d70dcc3d и design contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 00:23:35 +07:00
DenozordecandCursor c1132cbe19 feat(httpapi): remove firewall HTTP/UI after EvoFirewall cutover
CI / changes (push) Successful in 4s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 51s
CI / go (push) Successful in 59s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m11s
Все /v1/firewall/* отвечают 410 Gone; UI и docs указывают на EvoFirewall.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 19:51:08 +07:00
Denozordec b28ad88b22 feat(auth): enhance tenant resolution in JWT handling
CI / changes (push) Successful in 4s
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 58s
CI / commitlint (push) Skipped
CI / go (push) Successful in 1m3s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m15s
Updated the authentication logic to prioritize tenant resolution from JWT claims, specifically using `tenants.bgp` or `bgp_tenant_id` as the primary source. If these claims are absent, the system will fallback to the configured `EVOBGP_PORTAL_TENANT_ID`. This change improves the flexibility of tenant management in the authentication process. Additionally, updated related documentation to reflect these changes and added tests to ensure proper functionality.
2026-07-19 01:03:33 +07:00
Denozordec b871d62de6 feat(auth): add environment variables for portal SSO integration
Introduced new environment variables for the authentication portal in the production and microvps configurations. Updated the documentation to clarify the necessity of passing these variables to the evobgp-all service. This change enhances the authentication flow by enabling single sign-on (SSO) capabilities through JWT, ensuring a more secure and streamlined user experience.
2026-07-19 00:55:35 +07:00
DenozordecandCursor 4d83b8d673 feat(auth): integrate portal JWT for enhanced authentication and authorization
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 51s
CI / go (push) Successful in 2m19s
CI / bird2 (push) Successful in 13s
CI / release (push) Successful in 4m24s
Added support for portal JWT authentication, enabling single sign-on (SSO) capabilities. Updated the application to handle JWT claims for user permissions and roles, enhancing security and access control. Refactored relevant components and API routes to accommodate the new authentication flow, ensuring a seamless user experience. Updated documentation to reflect the new authentication requirements and configurations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 23:23:52 +07:00
DenozordecandCursor 2820cff988 refactor(web): streamline /network page with ReUI kit
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / web (push) Successful in 1m14s
CI / release (push) Successful in 4m23s
Сжать экран «Сеть»: KPI (stats-12) и вкладки Пиры/Спикеры через ResourcePage; убраны дубли charts и заглушка control-plane.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 01:12:42 +07:00
DenozordecandCursor 54b7ea3bd5 feat(lookup): resolve domain to IPs for membership check
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 1m6s
CI / go (push) Successful in 57s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m6s
Для FQDN после проверки DOMAINS выполняется live DNS (A/AAAA), каждый IP проверяется по IP_RANGES и snapshots; в ответе resolved_ips / resolved_ip, UI KPI и OpenAPI обновлены.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 01:01:54 +07:00
Denozordec 57bcfcd9e1 fix: improve error message for invalid input in lookup functionality
CI / changes (push) Successful in 5s
CI / openapi (push) Skipped
CI / web (push) Skipped
CI / commitlint (push) Skipped
CI / go (push) Successful in 54s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m51s
Updated the error response for invalid input in the handleLookup function to provide a clearer message indicating that the query must be an IP address or FQDN, enhancing user understanding of the input requirements.
2026-07-18 00:05:49 +07:00
Denozordec 1317a73e9a refactor: update AppShell and sidebar components for improved design consistency
CI / changes (push) Successful in 4s
CI / openapi (push) Skipped
CI / web (push) Successful in 1m4s
CI / commitlint (push) Skipped
CI / go (push) Failing after 14s
CI / bird2 (push) Skipped
CI / release (push) Skipped
Removed unused utility functions from the AppShell component and updated the sidebar width to a fixed value for better layout control. Revised the UI design documentation to clarify the shared App Shell chrome specifications and ensure alignment with design standards. Enhanced the sidebar configuration to reflect the new width and styling guidelines.
2026-07-17 23:56:35 +07:00
Denozordec 039d2f3dd9 refactor: update dashboard quick links and app shell for improved layout and functionality
CI / changes (push) Successful in 7s
CI / openapi (push) Skipped
CI / commitlint (push) Skipped
CI / web (push) Successful in 1m4s
CI / go (push) Failing after 17s
CI / bird2 (push) Skipped
CI / release (push) Skipped
Refactored the DashboardQuickLinks component to simplify icon classes for better semantic clarity. Updated the AppShell component to integrate the AppSwitcher and AppsMenu, enhancing navigation and user experience. Adjusted the layout of the app shell header and main content for improved consistency and alignment. Updated UI design documentation to reflect these changes and ensure adherence to shared design standards.
2026-07-17 23:15:00 +07:00
Denozordec 54a0b5b966 feat: add lookup functionality for IP/domain verification and enhance dashboard links
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 22s
CI / web (push) Successful in 49s
CI / go (push) Failing after 16s
CI / bird2 (push) Skipped
CI / release (push) Skipped
Introduced a new lookup feature allowing users to quickly verify IP addresses or domains against community lists. Updated the DashboardQuickLinks component to include a new action for IP/domain checks, enhancing user navigation. Expanded API documentation to include the new lookup endpoint and its response structure, ensuring comprehensive coverage of the feature. Updated UI design documentation to reflect the integration of the lookup functionality.
2026-07-17 20:53:11 +07:00
Denozordec 1639ba40f3 refactor: update dashboard components to integrate QuickActionGrid and enhance settings
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 33s
CI / web (push) Successful in 1m0s
CI / go (push) Successful in 56s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 4m14s
Removed the deprecated DashboardQuickLinkCard and replaced it with QuickActionGrid in the DashboardQuickLinks component for improved organization and user experience. Updated the AppearanceSettingsTab to include a toggle for displaying quick actions on the dashboard, enhancing user customization options. Adjusted the SystemMonitorPopover layout for better alignment and spacing. Updated documentation to reflect the new UI preferences for quick actions.
2026-07-17 20:34:25 +07:00
Denozordec 26a96bc824 refactor: update KPI components for improved layout and clarity
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 47s
CI / go (push) Successful in 52s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m21s
Revised the KpiStatGrid and KpiStatCardBody components to enhance the layout and presentation of KPI tiles. Updated the documentation to reflect the new horizontal compact hybrid design, ensuring better visual organization with the icon on the left and improved label and value alignment. Adjusted the skeleton loading state for a more cohesive user experience.
2026-07-17 18:37:00 +07:00
Denozordec 4fc5c96e63 refactor: enhance KPI components with variant support and improved layout
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 51s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m12s
Updated the KPI components to include a new KpiStatVariant type for better visual differentiation. Refactored the KpiStatCardBody and KpiStatGrid to utilize this variant, allowing for dynamic styling based on the KPI state. Improved the layout of the KpiStatGrid and its associated components for a more cohesive user experience, including adjustments to the Badge footer presentation.
2026-07-17 17:15:14 +07:00
Denozordec 3859983ae5 refactor: update UI components to utilize Frame for improved layout and consistency
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 1m7s
CI / go (push) Successful in 2m11s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m46s
Refactored multiple components, including PanelCard, SettingsCard, and DataGridShell, to replace Card with Frame for better organization and presentation. Enhanced the DataGridToolbar to support optional ReUI filters and improved the overall structure of the KPI stat grid. Updated .gitignore to include .env.local for local environment configurations, ensuring better management of environment variables.
2026-07-17 15:30:41 +07:00
Denozordec aa1779170a refactor: enhance settings and auth components for improved navigation and user feedback
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 54s
CI / go (push) Successful in 1m3s
CI / bird2 (push) Successful in 20s
CI / release (push) Successful in 4m29s
Updated the Settings and Auth components to include better navigation handling by adding search parameters for tab selection. Enhanced the Access component to utilize badges for status indicators, improving visual clarity. Refactored the Schedule component to streamline job status display with badges, ensuring a more cohesive user experience. Additionally, integrated client directives in UI components for better performance.
2026-07-09 23:39:10 +07:00
Denozordec bbcf9d76d9 refactor: enhance schedule components with improved filtering and layout
CI / changes (push) Successful in 11s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / web (push) Successful in 51s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
Updated the ScheduleAgendaPanel to include a job filter feature, allowing users to filter jobs by type (all, refresh, failed). Refactored the layout to integrate a new ScheduleCalendarView for better organization. Enhanced the ScheduleJobsGrid to conditionally display pagination based on the number of items. Additionally, modified the Schedule component to utilize the new ScheduleJobsCard for improved job display and loading states, ensuring a more cohesive user experience.
2026-07-09 23:10:20 +07:00
Denozordec 55eb2a6c89 refactor: enhance dashboard layout and settings components for improved user experience
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m7s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
Updated the AnalyticsDashboardSkeleton to utilize a new grid layout for KPI tiles, increasing the number of displayed items and adjusting their size for better visibility. Modified the SettingsSettingField component to accept a ReactNode for the description, allowing for richer content. Refactored the Settings component to improve the organization of API token management and session status display, enhancing clarity and usability. Additionally, integrated new UI elements for theme selection and improved layout consistency across components.
2026-07-09 21:58:39 +07:00
Denozordec a3f3ffd672 refactor: integrate PanelCard and enhance dashboard components for improved layout
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 52s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m7s
Refactored multiple dashboard components to utilize the new PanelCard for better organization and presentation. Updated the DashboardFramePanel, DashboardQuickLinks, and DashboardOperationsBreakdown components to streamline layouts and enhance user experience. Removed deprecated components and improved loading states in various sections, ensuring a cohesive interface throughout the application.
2026-07-09 21:39:20 +07:00
Denozordec 1a142e68a9 refactor: update Dashboard components for improved layout and integration
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 52s
CI / go (push) Successful in 59s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m20s
Refactored the DashboardQuickLinkCard to enhance its structure and presentation by integrating FramePanel. Updated the DashboardQuickLinks component to utilize Frame for better organization, including a header and description. Removed the previous DashboardFramePanel integration in the DashboardComponent to streamline the layout, ensuring a more cohesive user experience.
2026-07-09 21:15:04 +07:00
Denozordec 46e7b2aa6b chore: update BadgeTabs component to log additional layout metrics
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 1m0s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 22s
Added new layout metrics logging to the BadgeTabs component, enhancing debugging capabilities. This update includes additional timestamped entries for layout metrics, providing better insights into component behavior during runtime.
2026-07-09 21:06:33 +07:00
Denozordec 1152bb40f7 feat: enhance BadgeTabs and Auth components with layout improvements and API integration
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m7s
CI / go (push) Successful in 1m14s
CI / bird2 (push) Successful in 20s
CI / release (push) Successful in 4m52s
Added layout metrics logging to the BadgeTabs component for better debugging and analysis. Updated the Auth route to include a reason for redirection when a token is required, improving user feedback. Enhanced the Settings component to display an alert when API token access is needed, ensuring clarity for users. Additionally, refined the Tabs component to improve responsiveness based on orientation, contributing to a more cohesive user experience.
2026-07-09 20:54:35 +07:00
700 changed files with 26376 additions and 33956 deletions
+4 -4
View File
@@ -1,17 +1,17 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+4 -2
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project)
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
+48 -8
View File
@@ -1,24 +1,23 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useReactTable({
const table = useTable({
features: dataGridFeatures,
data,
columns,
getCoreRowModel: getCoreRowModel(),
// add sorting/pagination/selection models per the API
})
<DataGrid table={table} recordCount={data.length}>
@@ -28,9 +27,37 @@ const table = useReactTable({
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
@@ -284,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert
**Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index
+2 -2
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
+4 -4
View File
@@ -1,17 +1,17 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+4 -2
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project)
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
+48 -8
View File
@@ -1,24 +1,23 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useReactTable({
const table = useTable({
features: dataGridFeatures,
data,
columns,
getCoreRowModel: getCoreRowModel(),
// add sorting/pagination/selection models per the API
})
<DataGrid table={table} recordCount={data.length}>
@@ -28,9 +27,37 @@ const table = useReactTable({
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
@@ -284,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert
**Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index
+2 -2
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
-6
View File
@@ -1,6 +0,0 @@
{
"pid": 43636,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1783570299043
}
+16
View File
@@ -0,0 +1,16 @@
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101206}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605101204}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138640}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605138642}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605143758}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605144457}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605145907}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152669}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605152671}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222064}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605222067}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605819320}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605827145}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783605864391}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606471768}
{"sessionId":"c10bcb","runId":"post-fix","hypothesisId":"H1","location":"badge-tabs.tsx:useEffect","message":"BadgeTabs layout metrics","data":{"flexDirection":"column","orientation":"horizontal","listLeft":280,"panelLeft":280,"stacked":true},"timestamp":1783606472923}
+8
View File
@@ -0,0 +1,8 @@
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613834659}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613834740}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613854903}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613854967}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613897698}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613897769}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614164222}
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614181455}
@@ -11,9 +11,9 @@
]
},
"reui": {
"url": "https://mcp.reui.io/api/mcp?style=base-nova",
"url": "https://mcp.reui.io",
"headers": {
"X-Reui-Style": "base-nova"
"Authorization": "Bearer <REUI_LICENSE_KEY>"
}
}
}
+30
View File
@@ -0,0 +1,30 @@
---
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
alwaysApply: true
---
# KPI hybrid — только kit (stats-12 + IconTile)
Preview: [stats-12](https://reui.io/preview/base/stats-12). Primitive: [icon-tile](https://reui.io/docs/components/base/icon-tile). SoT DNA = EvoBGP. Markup: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc).
## MUST
| Зона | Компонент / DNA |
|------|-----------------|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на IconTile elevated |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
Импорты UI: `@evobgp/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
## NEVER
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile
- Другой size / radius / solid brand fill вместо `elevated`
- `card-35` как замена stats-12 hybrid KPI
- Копипаст ReUI block в route — adapt через `reui-kit/`
- Голый lucide `size-4` в name-cell без IconTile
+121
View File
@@ -0,0 +1,121 @@
---
description: ReUI PRO (@reui) — MCP user-reui, Frame surface, kit, license, матрица выбора
alwaysApply: true
---
# ReUI MCP — обязательно (PRO + free)
Проект: **Base UI** (`style: base-nova`), surface lock **`frame`**.
Связанные: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`docs/ui-design-contract.md`](docs/ui-design-contract.md).
| Документ | URL |
|----------|-----|
| **Introduction** | https://reui.io/docs |
| **llms.txt** | https://reui.io/llms.txt |
| **Get Started** | https://reui.io/docs/get-started |
| **Styling** | https://reui.io/docs/styling |
| **Registry** | https://reui.io/docs/registry |
| **MCP** | https://reui.io/docs/mcp |
| **Agent Skills** | https://reui.io/docs/agent-skills |
| **Cursor MCP** | https://reui.io/docs/cursor |
| **Blocks** | https://reui.io/blocks |
| **Settings blocks** | https://reui.io/blocks/application/settings |
| **License** | https://reui.io/docs/license-setup |
| **Base UI components** | https://reui.io/docs/components/base/<name> |
## Primary MCP
1. **`user-reui`** — `search` / `compose_page` / `get_block` / `get_component` / `get_install_command` / `validate_usage` / `get_audit_checklist`
2. **`plugin-shadcn-shadcn`** — primitives `@shadcn`; для `@reui` — вторично
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
**Registry (актуально):** 20 free components — `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.claude/skills/reui` (v `668fb463eb`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
**Важно:** skill описывает текущий registry (в т.ч. data-grid на TanStack Table v9). Установленный в проекте `@reui/data-grid` может оставаться на v8 до явного CLI upgrade — не ломать kit без миграции.
## Когда ReUI vs shadcn
| Задача | Registry | Импорт |
|--------|----------|--------|
| Button, Sheet, Field, Sidebar, Tabs | `@shadcn` | `@evobgp/ui/components/*` |
| PRO pages/sections (settings, stats, auth, dashboard) | `@reui` blocks | adapt → `apps/web/src/components/` / `reui-kit/` |
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
| Filters | `@reui` | `@/components/reui/filters` |
| Frame surface | `@reui` | `@/components/reui/frame` |
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) |
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
| Number / date / autocomplete / color / kanban / timeline / stepper / tree | `@reui` | `@/components/reui/*` |
| Event Calendar / Gantt / Icon Tile (registry, 20 free) | `@reui` | CLI `@reui/<name>` → `@/components/reui/*` при установке |
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не DataGridCard.
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
## MCP workflow
0. Codegraph / поиск существующих `reui-kit/*`, `@/components/reui/*`
1. `user-reui` `search` (`surface: "frame"`, `category` при известном)
2. Страница целиком → `compose_page`; секция → `get_block`
3. `get_component` для API primitives из `componentsUsed`
4. CLI из `apps/web`: `pnpm dlx shadcn@latest add @reui/<name> --yes`
5. Post-add: shadcn imports → `@evobgp/ui/components/*`
6. Adapt by reuse → kit / route
7. `validate_usage` + `get_audit_checklist`
## Размещение
| Слой | Путь | Импорт |
|------|------|--------|
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
## License
```env
# .env.local (gitignored)
REUI_LICENSE_KEY=
```
`apps/web/components.json` → `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
## Эталоны preview
| Зона | Preview |
|------|---------|
| KPI / Quick Actions | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
| List | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | https://reui.io/preview/base/settings-16 |
| Auth | https://reui.io/preview/base/auth-13 |
| Shell | https://reui.io/preview/base/app-shell-12 |
| Empty | https://reui.io/preview/base/empty-state-12 |
## Запрещено
- Копипаст с reui.io без CLI
- ReUI в `packages/ui` / импорт как `@evobgp/ui`
- Radix-варианты docs — только Base UI
- Raw `bg-emerald-*` вместо ReUI `variant`
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
- Смешивать Card и Frame на ops-экране
- Ставить shadcn/ui blocks выше ReUI PRO
- **settings-7** как target (`surface: card`) — только [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16)
## Чеклист
- [ ] `user-reui` search/get_block + previewUrl
- [ ] `surface: frame`
- [ ] CLI add из `apps/web` при новом item
- [ ] Kit / `@/components/reui` / `@evobgp/ui` — правильный слой
- [ ] `pnpm --filter @evobgp/web run build`
+1 -1
View File
@@ -11,7 +11,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+19 -12
View File
@@ -9,12 +9,12 @@ alwaysApply: false
# Web UI — React + shadcn/ui + ReUI
**Источники правды:**
- **ReUI PRO first:** MCP `user-reui` ([`reui-mcp.mdc`](reui-mcp.mdc)) — pages / KPI / lists / settings / shell
- shadcn primitives: MCP `plugin-shadcn-shadcn` (secondary)
- ReUI Base UI: https://reui.io/docs/components/base/<name> · [llms.txt](https://reui.io/llms.txt)
- shadcn/ui React: https://ui.shadcn.com/docs/components
- ReUI Base UI: https://reui.io/docs/components/base/<name>
- ReUI llms.txt: https://reui.io/llms.txt
- MCP `plugin-shadcn-shadcn` (registries: `@shadcn`, `@reui`) — перед любой UI-задачей
Общие правила Go/API: `.cursor/rules/engineering.mdc`. Стек ID: `.cursor/rules/context7-stack.mdc`.
Иерархия: **ReUI PRO > shadcn**. Общие: `.cursor/rules/engineering.mdc`, `context7-stack.mdc`.
## Слои UI
@@ -22,16 +22,19 @@ alwaysApply: false
|------|------|------------|
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton |
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell |
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Quick Actions: `QuickActionGrid`.
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
## Правила
**WEB-01** | MUST | Перед новым UI — MCP `plugin-shadcn-shadcn`: `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items`. Только после — JSX.
*Rationale:* единый источник правды и API.
*Проверка:* review; нет самописных примитивов, если есть registry item.
**WEB-01** | MUST | Перед новым UI — сначала MCP **`user-reui`** (`search` → `get_block` / `compose_page`, `surface: "frame"`) + cite `previewUrl`/`docsUrl`. Primitives — MCP `plugin-shadcn-shadcn`. Только после — JSX.
*Rationale:* ReUI PRO выше shadcn; единый Frame surface.
*Проверка:* review; [`reui-mcp.mdc`](reui-mcp.mdc).
**WEB-02** | MUST | Отсутствующий shadcn-примитив — `pnpm dlx shadcn@latest add <component>` (из `apps/web`). ReUI — `pnpm dlx shadcn@latest add @reui/<name>`.
*Проверка:* файлы в `packages/ui/src/components/` (для shadcn) или `apps/web/src/components/reui/` (для ReUI).
@@ -45,8 +48,8 @@ alwaysApply: false
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
*Проверка:* https://ui.shadcn.com/docs/components/form
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`.
*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/table`.
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
*Проверка:* https://ui.shadcn.com/docs/components/sonner
@@ -70,7 +73,7 @@ alwaysApply: false
**WEB-14** | SHOULD | Нетривиальный UI — прочитать страницу компонента shadcn/ReUI (props, a11y).
**WEB-15** | MUST | Сомнения — MCP `plugin-shadcn-shadcn` + shadcn CLI docs + `pnpm --filter @evobgp/web run typecheck`.
**WEB-15** | MUST | Сомнения — MCP `user-reui` + `plugin-shadcn-shadcn` + docs + `pnpm --filter @evobgp/web run typecheck`.
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из `@/components/confirm-dialog`, не `window.confirm`.
@@ -95,7 +98,11 @@ pnpm --filter @evobgp/web run build
**WEB-21** | MUST | Data fetching — TanStack Query (`useQuery`, `useMutation`, `queryOptions`); query-key factories в `apps/web/src/queries/`. Mutations invalidate keys, не refetch вручную.
*Проверка:* review `queries/*.ts`.
**WEB-22** | MUST | Legacy Svelte — в `web-legacy-svelte/` (archive). Не использовать импорты оттуда в новом коде; только как референс при миграции роутов.
**WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код.
*Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`.
**WEB-23** | NEVER | **settings-7** как target ([preview](https://reui.io/preview/base/settings-7) — `surface: card`). Новые settings-экраны — [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16) (`surface: frame`). Существующий `apps/web/src/components/settings/*` на DNA settings-7 **не** копировать как эталон; миграция — отдельный CHANGE.
*Проверка:* review MCP `get_block` / cite; нет новых экранов с settings-7.
---
+4 -4
View File
@@ -1,17 +1,17 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+4 -2
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project)
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
+48 -8
View File
@@ -1,24 +1,23 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useReactTable({
const table = useTable({
features: dataGridFeatures,
data,
columns,
getCoreRowModel: getCoreRowModel(),
// add sorting/pagination/selection models per the API
})
<DataGrid table={table} recordCount={data.length}>
@@ -28,9 +27,37 @@ const table = useReactTable({
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
@@ -284,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert
**Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index
+2 -2
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
+2
View File
@@ -28,6 +28,8 @@ Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
Повтор упавшего **release** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-release).
### Секреты
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
+28
View File
@@ -0,0 +1,28 @@
## Summary
<!-- 13 bullets: what and why -->
## Checklist
### General
- [ ] Docs updated if behavior/API changed (DOC-02)
- [ ] Conventional Commits (EN title / RU body)
### Backend (if Go / OpenAPI / migrations)
- [ ] `go test ./... -race -count=1` (or scoped packages) when touching jobs/pipeline/httpapi
- [ ] OpenAPI lint + regenerate `apps/web/src/types/api.gen.ts` if `docs/openapi.yaml` changed
- [ ] Migration pairs postgres+sqlite (DEP-03)
### Frontend (if `apps/web` / `packages/ui`) — WEB-19
- [ ] `pnpm --filter @evobgp/web run typecheck`
- [ ] `pnpm --filter @evobgp/web run lint`
- [ ] `pnpm --filter @evobgp/web run build`
- [ ] `pnpm --filter @evobgp/web run test`
- [ ] UI follows ReUI PRO + `docs/ui-design-contract.md` (surface `frame`, kit; cite previewUrl)
### KPI / Quick Actions
- [ ] If changing `kpi-stat-grid.tsx` / `quick-action-grid.tsx`, note sibling-app sync (vps / CFDM / fw / auth-portal)
+28 -4
View File
@@ -150,15 +150,23 @@ jobs:
# ---------------------------------------------------------------------------
openapi:
needs: [changes]
if: needs.changes.outputs.openapi == 'true'
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
- name: Enable pnpm via corepack
run: corepack enable
- name: Lint OpenAPI (Redocly)
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
- name: Check OpenAPI→TS codegen is fresh
run: |
set -euxo pipefail
pnpm install --frozen-lockfile
chmod +x scripts/check-openapi-gen.sh
sh scripts/check-openapi-gen.sh
# ---------------------------------------------------------------------------
web:
@@ -172,12 +180,13 @@ jobs:
node-version: "22"
- name: Enable pnpm via corepack
run: corepack enable
- name: pnpm install, typecheck, lint, build
- name: pnpm install, typecheck, lint, test, build
run: |
set -euxo pipefail
pnpm install --frozen-lockfile
pnpm --filter @evobgp/web run typecheck
pnpm --filter @evobgp/web run lint
pnpm --filter @evobgp/web run test
pnpm --filter @evobgp/web run build
# ---------------------------------------------------------------------------
@@ -294,6 +303,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
persist-credentials: true
- uses: actions/setup-node@v4
@@ -314,8 +324,22 @@ jobs:
id: rel
run: |
set -euo pipefail
version=""
if [ -f .release-version ]; then
echo "version=$(tr -d '[:space:]' < .release-version)" >> "$GITHUB_OUTPUT"
version="$(tr -d '[:space:]' < .release-version)"
echo "New release from semantic-release: $version"
else
# Re-run after a failed docker step: tag already exists, successCmd
# did not write .release-version (semantic-release is a no-op).
git fetch --tags --force origin || true
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
if [ -n "${tag:-}" ]; then
version="${tag#v}"
echo "Reuse existing tag $tag on HEAD (release retry)"
fi
fi
if [ -n "${version:-}" ]; then
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "released=true" >> "$GITHUB_OUTPUT"
else
echo "released=false" >> "$GITHUB_OUTPUT"
+5
View File
@@ -21,9 +21,14 @@ Thumbs.db
# Env / secrets (local only)
.env
.env.local
.env.*
!.env.example
!.env.*.example
# Compose runtime log sidecar output (deploy/compose/runtime-logs)
deploy/compose/runtime-logs/
# Local MCP configs (may contain REUI license Bearer)
.cursor/mcp.json
.mcp.json
.codegraph/daemon.pid
-16
View File
@@ -1,16 +0,0 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp"
]
},
"reui": {
"type": "http",
"url": "https://mcp.reui.io/api/mcp"
}
}
}
+4 -4
View File
@@ -1,17 +1,17 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+4 -2
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project)
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
+48 -8
View File
@@ -1,24 +1,23 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useReactTable({
const table = useTable({
features: dataGridFeatures,
data,
columns,
getCoreRowModel: getCoreRowModel(),
// add sorting/pagination/selection models per the API
})
<DataGrid table={table} recordCount={data.length}>
@@ -28,9 +27,37 @@ const table = useReactTable({
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
@@ -284,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert
**Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index
+2 -2
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
+6 -2
View File
@@ -69,6 +69,10 @@ pnpm --filter @evobgp/web run build
Все три команды должны exit 0. CI job `web` не пропускает без этого.
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte UI удалён (миграция завершена).
UI-задачи начинаются с MCP `plugin-shadcn-shadcn` (search → examples → add command), затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
**UI design contract:** [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface `frame`, kit `apps/web/src/components/reui-kit/`.
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills). Skill: `.claude/skills/reui` (`668fb463eb`).
UI-задачи начинаются с MCP `user-reui` (`surface: "frame"`) + `plugin-shadcn-shadcn`, затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
+28
View File
@@ -0,0 +1,28 @@
# EvoBGP web (Vite) — переменные окружения.
# Скопируйте в apps/web/.env.local (файл в .gitignore) и заполните.
# ---------------------------------------------------------------------------
# ReUI Pro/Ultimate — ключ с https://reui.io/account (для `pnpm dlx shadcn add @reui/*`)
# ---------------------------------------------------------------------------
# REUI_LICENSE_KEY=
# ---------------------------------------------------------------------------
# App Switcher — JSON с описанием шапки «Приложения» (fallback, когда portal
# недоступен либо VITE_AUTH_ENABLED=false).
# Схема: см. apps/web/src/lib/app-switcher-config.ts.
# ---------------------------------------------------------------------------
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[...]}
# ---------------------------------------------------------------------------
# Auth-portal SSO
# ---------------------------------------------------------------------------
# Включает JWT-гейт через auth-portal вместо локального evobgp_api_token.
# Пример:
# VITE_AUTH_ENABLED=true
# VITE_AUTH_PORTAL_URL=https://auth.shnt.top
#
# Backend опционально может отдавать GET /v1/auth/config
# ({ "required": true, "portal_url": "https://auth.shnt.top" }) —
# ответ имеет приоритет над VITE_* и позволяет менять режим без пересборки.
# VITE_AUTH_ENABLED=false
# VITE_AUTH_PORTAL_URL=http://localhost:5175
+32
View File
@@ -0,0 +1,32 @@
import { test, expect } from '@playwright/test'
/**
* Smoke against local API (+ optional UI). Profile: compose microvps-full.
*
* Env:
* - EVOBGP_E2E_API_URL (default http://127.0.0.1:8080)
* - EVOBGP_E2E_BASE_URL (default http://127.0.0.1:5173)
* - EVOBGP_E2E_TOKEN (default dev)
*/
const apiBase = process.env.EVOBGP_E2E_API_URL ?? 'http://127.0.0.1:8080'
const uiBase = process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173'
const token = process.env.EVOBGP_E2E_TOKEN ?? 'dev'
test.describe('EvoBGP smoke', () => {
test('API health and modules list', async ({ request }) => {
const health = await request.get(`${apiBase}/v1/health`)
expect(health.ok()).toBeTruthy()
const mods = await request.get(`${apiBase}/v1/modules?limit=10`, {
headers: { Authorization: `Bearer ${token}` },
})
expect([200, 401]).toContain(mods.status())
})
test('UI loads when available', async ({ page }) => {
test.skip(!process.env.EVOBGP_E2E_UI, 'set EVOBGP_E2E_UI=1 to enable UI smoke')
await page.goto(uiBase)
await page.waitForLoadState('domcontentloaded')
await expect(page.locator('body')).toBeVisible()
})
})
+9 -4
View File
@@ -9,7 +9,10 @@
"preview": "vite preview",
"typecheck": "tsr generate && tsc --noEmit",
"lint": "eslint .",
"test": "vitest run"
"test": "vitest run",
"test:e2e": "playwright test",
"openapi:gen": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.ts",
"openapi:check": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.check.ts && diff -q src/types/api.gen.ts src/types/api.gen.check.ts && rm -f src/types/api.gen.check.ts"
},
"dependencies": {
"@base-ui/react": "^1.0.0",
@@ -40,22 +43,24 @@
"zod": "^3.25.0"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@playwright/test": "^1.62.1",
"@tailwindcss/vite": "^4.1.0",
"@tanstack/router-plugin": "^1.130.0",
"@tanstack/router-cli": "^1.130.0",
"@tanstack/router-plugin": "^1.130.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.0.0",
"@eslint/js": "^9.0.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.0",
"globals": "^15.0.0",
"typescript-eslint": "^8.0.0",
"happy-dom": "^18.0.0",
"openapi-typescript": "^7.13.0",
"tailwindcss": "^4.1.0",
"tw-animate-css": "^1.0.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.0.0",
"vite": "^7.3.1",
"vitest": "^3.0.0"
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 60_000,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
use: {
baseURL: process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173',
trace: 'on-first-retry',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
})
@@ -4,7 +4,7 @@ import { Plus, RefreshCw } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
import { DataGridCard } from '@/components/data-grid-shell'
import { FrameDataGrid } from '@/components/reui-kit'
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
import { QueryState } from '@/components/query-state'
@@ -47,7 +47,7 @@ export function AccessApiKeysCard({
return (
<>
<DataGridCard
<FrameDataGrid
title="API-ключи"
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
actions={
@@ -85,7 +85,7 @@ export function AccessApiKeysCard({
/>
)}
</QueryState>
</DataGridCard>
</FrameDataGrid>
<ApiKeyCreateDialog
open={createOpen}
@@ -1,5 +1,5 @@
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
export type AnalyticsKpiItem = {
@@ -12,39 +12,42 @@ export type AnalyticsKpiItem = {
}
}
const TONE_CLASS = {
success: 'text-success',
warning: 'text-warning',
destructive: 'text-destructive',
muted: 'text-muted-foreground',
} as const
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
if (direction === 'up') return <TrendingUp className="size-3" />
if (direction === 'down') return <TrendingDown className="size-3" />
return <Minus className="size-3" />
function deltaBadgeVariant(
tone?: 'success' | 'warning' | 'destructive' | 'muted',
): 'success-light' | 'warning-light' | 'destructive-light' | 'outline' {
if (tone === 'success') return 'success-light'
if (tone === 'warning') return 'warning-light'
if (tone === 'destructive') return 'destructive-light'
return 'outline'
}
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
function toKpiItem(item: AnalyticsKpiItem): KpiStatItem {
return {
id: item.label,
value: item.value,
label: item.label,
footer: item.delta ? (
<Badge variant={deltaBadgeVariant(item.delta.tone)} size="sm">
{item.delta.label}
</Badge>
) : undefined,
}
}
/** Compact KPI row inside analytics panels (stats-12 embedded tiles). */
export function AnalyticsKpiRow({
items,
className,
}: {
items: AnalyticsKpiItem[]
className?: string
}) {
return (
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
{items.map((item) => (
<div key={item.label} className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
{item.delta ? (
<p
className={cn(
'flex items-center gap-1 text-xs',
TONE_CLASS[item.delta.tone ?? 'muted'],
)}
>
<DeltaIcon direction={item.delta.direction} />
{item.delta.label}
</p>
) : null}
</div>
))}
</div>
<KpiStatGrid
items={items.map(toKpiItem)}
embedded
className={cn(className)}
aria-label="Показатели"
/>
)
}
@@ -40,7 +40,7 @@ export function ChartDonutMetric({
}
return (
<div className={cn('flex items-center gap-6', className)}>
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
<PieChart>
<Pie
@@ -75,7 +75,7 @@ export function ChartDonutMetric({
</PieChart>
</ChartContainer>
<ul className="min-w-0 flex-1 space-y-3">
<ul className="flex w-full min-w-0 max-w-xs flex-col gap-3 sm:w-auto sm:min-w-[10rem]">
{slices.map((slice) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
@@ -10,4 +10,3 @@ export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
export { DashboardPlatformCard } from './dashboard-platform-card'
export { MonitoringHealthCard } from './monitoring-health-card'
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
export { OperationsAnalyticsCard } from './operations-analytics-card'
@@ -1,5 +1,4 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { DonutBreakdownCard } from '@/components/patterns/donut-breakdown-card'
import { readinessBreakdown } from '@/lib/metrics'
import type { ReadyStatus } from '@/queries/monitoring'
@@ -13,21 +12,25 @@ export function MonitoringHealthCard({
loading?: boolean
}) {
const slices = readinessBreakdown(ready, healthOk)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
if (loading) {
return (
<DonutBreakdownCard
title="Доступность системы"
description="Проверки живучести и готовности"
slices={[]}
centerLabel="Проверки"
/>
)
}
return (
<AnalyticsCardShell
<DonutBreakdownCard
title="Доступность системы"
description="Проверки живучести и готовности"
info="Диаграмма отражает результат GET /v1/health и проверок из GET /v1/ready."
>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Проверки" centerValue={total} />
)}
</AnalyticsCardShell>
description="GET /v1/health · GET /v1/ready"
slices={slices}
centerLabel="Проверки"
badge={healthOk ? 'API OK' : undefined}
/>
)
}
@@ -1,6 +1,5 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { DonutBreakdownCard } from '@/components/patterns/donut-breakdown-card'
import { SegmentedProgressCard } from '@/components/patterns/segmented-progress-card'
import { peerSessionBreakdown } from '@/lib/metrics'
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { PeerRow, SpeakerRow } from '@/types/api'
@@ -16,51 +15,44 @@ export function NetworkOverviewAnalyticsCard({
}) {
const net = aggregateNetworkMetrics(peers, speakers)
const slices = peerSessionBreakdown(peers)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
const speakersPct =
net.speakersTotal > 0 ? Math.round((net.speakersOnline / net.speakersTotal) * 100) : 0
if (loading) {
return (
<DonutBreakdownCard
title="Сводка BGP"
description="Установленные сессии и спикеры"
slices={[]}
centerLabel="Пиры"
/>
)
}
return (
<AnalyticsCardShell
title="Сводка BGP"
description="Установленные сессии, доступность спикеров и расхождения по live-данным"
info="Снимок текущего состояния пиров и спикеров."
>
<AnalyticsKpiRow
items={[
{
label: 'Пиры с установленной сессией',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
delta: {
direction: net.peersMismatch > 0 ? 'down' : 'up',
label: net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'сессии в норме',
tone: net.peersMismatch > 0 ? 'warning' : 'success',
},
},
{
label: 'Спикеры в сети',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
delta: {
direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up',
label:
net.speakersOnline < net.speakersTotal
? `${net.speakersTotal - net.speakersOnline} не в сети`
: 'все в сети',
tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success',
},
},
{
label: 'Пиры всего',
value: loading ? '—' : String(net.peersTotal),
delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' },
},
]}
<div className="grid gap-4 lg:grid-cols-2">
<DonutBreakdownCard
title="Сводка BGP"
description="Распределение состояний пиров"
slices={slices}
centerLabel="Пиры"
badge={net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'в норме'}
/>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
)}
</AnalyticsCardShell>
<SegmentedProgressCard
title="Спикеры"
description="Доступность live-агентов"
primary={{
value: `${net.speakersOnline}/${net.speakersTotal}`,
label: 'Online',
percent: speakersPct,
}}
secondary={{
value: net.speakersTotal - net.speakersOnline,
label: 'Offline',
percent: 100 - speakersPct,
}}
footer={`Пиры Established: ${net.peersEstablished}/${net.peersEnabled}`}
/>
</div>
)
}
@@ -1,65 +0,0 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { jobStatusBreakdown } from '@/lib/metrics'
import type { JobRow, RevisionRow } from '@/types/api'
export function OperationsAnalyticsCard({
jobs,
revisions,
loading,
}: {
jobs: JobRow[]
revisions: RevisionRow[]
loading?: boolean
}) {
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const slices = jobStatusBreakdown(jobs)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
return (
<AnalyticsCardShell
title="Операции и задачи"
description="Статистика ревизий и фоновых jobs"
info="Данные из GET /v1/jobs и /v1/revisions."
>
<AnalyticsKpiRow
items={[
{
label: 'Ревизий',
value: loading ? '—' : String(revisions.length),
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
},
{
label: 'Активных задач',
value: loading ? '—' : String(running),
delta: {
direction: running > 0 ? 'up' : 'neutral',
label: running > 0 ? 'выполняются' : 'очередь пуста',
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
},
},
{
label: 'С ошибкой',
value: loading ? '—' : String(failed),
delta: {
direction: failed > 0 ? 'down' : 'up',
label: failed > 0 ? 'требуют внимания' : 'в норме',
tone: failed > 0 ? 'destructive' : 'success',
},
},
]}
/>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
+97
View File
@@ -0,0 +1,97 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@evobgp/ui/components/sidebar'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
getCurrentApp,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
/** Sidebar app switcher — shared chrome etalon EvoBGP. @see https://reui.io/preview/base/app-shell-12 */
export function AppSwitcher() {
const { isMobile } = useSidebar()
const { config, isLoading } = useAppSwitcherConfig()
const current = getCurrentApp(config)
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<div
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
aria-hidden
>
<CurrentIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{current.name}</span>
{current.subtitle ? (
<span className="truncate text-xs text-muted-foreground">
{current.subtitle}
</span>
) : null}
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{isLoading ? 'Загрузка…' : config.menuLabel}
</div>
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon]
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem key={app.id} disabled>
<Icon />
{app.name}
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
>
<Icon />
{app.name}
{app.shortcut ? (
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
+2 -4
View File
@@ -40,14 +40,12 @@ export function BadgeTabs({
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
orientation="horizontal"
className={cn('w-full', className)}
>
<TabsList
variant="line"
className={cn(
'mb-4 w-full justify-start gap-6',
listClassName,
)}
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="gap-2">
@@ -1,99 +0,0 @@
"use client"
import { useEffect, useRef } from "react"
import { cn } from "@evobgp/ui/lib/utils"
// Deterministic per-dot value so the field reads as noise, not a flat grid.
function grain(i: number, j: number) {
const n = Math.sin(i * 127.1 + j * 311.7) * 43758.5453
return n - Math.floor(n)
}
/**
* Static dot field adapted from auth-3's WaveDots backdrop with the twinkle
* animation removed: a dense grid of dots at a fixed per-dot brightness,
* painted once and again on resize or theme change. The dot color resolves
* from the canvas `color` token so it stays neutral in light and dark.
* customize: GAP (density), DOT (size), PEAK (max brightness).
*/
export function CardDotField({ className }: { className?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const GAP = 3 // dot spacing in px (tight grid, still distinct dots)
const DOT = 1.5 // dot side in px
const BASE = 0.05 // dim end of a dot
const PEAK = 0.32 // bright end of a dot (kept subtle for a card surface)
const draw = () => {
const rect = canvas.getBoundingClientRect()
if (!rect.width || !rect.height) return
// Resetting width clears the canvas and restores the identity transform,
// so the color probe below reads a raw device pixel.
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = Math.round(rect.width * dpr)
canvas.height = Math.round(rect.height * dpr)
// Resolve the token to concrete sRGB by painting + reading it back, so
// oklch never casts a color and dark mode recolors on theme change.
ctx.fillStyle = getComputedStyle(canvas).color || "rgb(115,115,115)"
ctx.fillRect(0, 0, 1, 1)
const px = ctx.getImageData(0, 0, 1, 1).data
const color = `rgb(${px[0]}, ${px[1]}, ${px[2]})`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, rect.width, rect.height)
ctx.fillStyle = color
const cols = Math.ceil(rect.width / GAP) + 1
const rows = Math.ceil(rect.height / GAP) + 1
for (let i = 0; i < cols; i++) {
const x = i * GAP
for (let j = 0; j < rows; j++) {
const q = grain(i, j)
const amp = 0.7 + 0.6 * grain(j * 2 + 1, i * 2 + 1)
let a = (BASE + (PEAK - BASE) * q * q) * amp
if (a > 1) a = 1
ctx.globalAlpha = a
ctx.fillRect(x, j * GAP, DOT, DOT)
}
}
ctx.globalAlpha = 1
}
draw()
const resizeObserver = new ResizeObserver(() => draw())
resizeObserver.observe(canvas)
// Repaint when the theme class toggles so the resolved color stays correct.
const themeObserver = new MutationObserver(() => draw())
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style"],
})
return () => {
resizeObserver.disconnect()
themeObserver.disconnect()
}
}, [])
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full",
className
)}
/>
)
}
@@ -1,17 +0,0 @@
import { Frame } from "@/components/reui/frame"
import { CardItem } from "./card-item"
import { CARDS } from "./data"
export function CardGrid() {
return (
<Frame className="@container w-full">
{/* Grid */}
<div className="grid gap-1 @2xl:grid-cols-2">
{CARDS.map((card) => (
<CardItem key={card.title} card={card} />
))}
</div>
</Frame>
)
}
@@ -1,44 +0,0 @@
import { FramePanel } from "@/components/reui/frame"
import { cn } from "@evobgp/ui/lib/utils"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import { CardDotField } from "./card-dot-field"
import { ICard } from "./data"
import { ChevronRightIcon } from "lucide-react"
export function CardItem({ card }: { card: ICard }) {
return (
<FramePanel className="isolate">
{/* Card */}
<CardDotField className="text-muted-foreground [mask-image:linear-gradient(to_bottom_left,black,transparent_60%)]" />
<div className="relative z-10 space-y-7.5">
<Item
className={cn(
"p-0",
"border-background flex size-11 items-center justify-center border-2 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white",
card.iconBg
)}
>
<ItemMedia variant="icon" className="size-auto">
{card.icon}
</ItemMedia>
</Item>
<div className="flex flex-col gap-3">
<span className="block text-sm leading-tight font-medium">
{card.title}
</span>
<p className="text-muted-foreground text-xs leading-relaxed">
{card.description}
</p>
<a
href="#"
className="text-primay inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{card.link}
<ChevronRightIcon aria-hidden="true" className="size-2.5 shrink-0" />
</a>
</div>
</div>
</FramePanel>
)
}
@@ -1,53 +0,0 @@
import { type ReactNode } from "react"
import { ShoppingBagIcon, TrendingUp, BarChart3Icon, Settings2Icon } from "lucide-react"
export interface ICard {
title: string
description: string
link: string
icon: ReactNode
iconBg: string
}
export const CARDS: ICard[] = [
{
title: "Recent Orders Overview",
description:
"Track and review all recent purchases, updates, and status changes in one place.",
link: "View Orders",
icon: (
<ShoppingBagIcon aria-hidden="true" />
),
iconBg: "bg-green-600",
},
{
title: "Active Opportunities Pipeline",
description:
"Monitor ongoing deals, check potential revenue, and update opportunity stages.",
link: "Open Pipeline",
icon: (
<TrendingUp aria-hidden="true" />
),
iconBg: "bg-indigo-600",
},
{
title: "Performance & Sales Reports",
description:
"Analyze weekly and monthly reports to gain deeper insights into performance trends.",
link: "View Reports",
icon: (
<BarChart3Icon aria-hidden="true" />
),
iconBg: "bg-sky-600",
},
{
title: "Integration Settings & Sync",
description:
"Manage connections with third-party tools and ensure data stays in sync.",
link: "Manage Integrations",
icon: (
<Settings2Icon aria-hidden="true" />
),
iconBg: "bg-orange-600",
},
]
@@ -1,9 +0,0 @@
import { CardGrid } from "./components/card-grid"
export function Page() {
return (
<div className="flex min-h-svh w-full max-w-4xl items-center justify-center p-8">
<CardGrid />
</div>
)
}
@@ -1,15 +0,0 @@
import { CardItem } from "./card-item"
import { CARDS } from "./data"
export function CardGrid() {
return (
<div className="@container w-full">
{/* Grid */}
<div className="grid gap-5 @2xl:grid-cols-3">
{CARDS.map((card) => (
<CardItem key={card.label} card={card} />
))}
</div>
</div>
)
}
@@ -1,34 +0,0 @@
import {
Frame,
FrameHeader,
FramePanel,
} from "@/components/reui/frame"
import { ICard } from "./data"
import { LinkIcon } from "lucide-react"
export function CardItem({ card }: { card: ICard }) {
return (
<Frame spacing="sm">
{/* Header */}
<FrameHeader className="px-1! py-1!">
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
{card.icon}
<span className="text-foreground text-sm font-medium">
{card.label}
</span>
</div>
</FrameHeader>
{/* Content */}
<FramePanel className="space-y-3.5">
<p className="text-xs leading-relaxed">{card.description}</p>
<a
href="#"
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
>
<LinkIcon aria-hidden="true" className="size-2.5 shrink-0" />
{card.link}
</a>
</FramePanel>
</Frame>
)
}
@@ -1,39 +0,0 @@
import { type ReactNode } from "react"
import { PackageIcon, TrendingUp, MapPinIcon } from "lucide-react"
export interface ICard {
label: string
icon: ReactNode
description: string
link: string
}
export const CARDS: ICard[] = [
{
label: "Binance",
icon: (
<PackageIcon aria-hidden="true" />
),
description:
"Track trading volumes, liquidity shifts, and price movements for informed decisions",
link: "https://www.binance.com/en/markets/over..",
},
{
label: "Revenue",
icon: (
<TrendingUp aria-hidden="true" />
),
description:
"Get instant insights into earnings and cash flow performance.",
link: "https://nexo.io/earn/crypto-detailed-portfol..",
},
{
label: "Shipments",
icon: (
<MapPinIcon aria-hidden="true" />
),
description:
"Stay on top of deliveries and track shipment statuses efficiently.",
link: "https://www.educare.io/platform/analytics/e..",
},
]
@@ -1,9 +0,0 @@
import { CardGrid } from "./components/card-grid"
export function Page() {
return (
<div className="flex min-h-svh w-full max-w-5xl items-center justify-center p-6">
<CardGrid />
</div>
)
}
@@ -1,416 +0,0 @@
import { Frame, FramePanel } from "@/components/reui/frame"
import { Cell, Pie, PieChart } from "recharts"
import { cn } from "@evobgp/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from "@evobgp/ui/components/avatar"
import { Button } from "@evobgp/ui/components/button"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@evobgp/ui/components/chart"
import { Separator } from "@evobgp/ui/components/separator"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@evobgp/ui/components/tooltip"
import {
allocationMemberCount,
allocationMembers,
allocationPeriods,
inflowChartConfig,
inflowPeriods,
SEGMENT_COUNT,
type AllocationPeriod,
type InflowFund,
type InflowPeriod,
} from "./data"
import { InfoIcon } from "lucide-react"
const segments = Array.from({ length: SEGMENT_COUNT }, (_, index) => index)
const CHART_REVEAL_STYLE = `
@keyframes dashboard-1-flow-reveal-up {
from {
clip-path: inset(100% 0 0 0);
opacity: 0.75;
}
to {
clip-path: inset(0 0 0 0);
opacity: 1;
}
}
.dashboard-1-flow-reveal-up {
animation: dashboard-1-flow-reveal-up 680ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.dashboard-1-flow-reveal-up {
animation: none;
clip-path: none;
opacity: 1;
}
}
`
function AllocationMeter({ period }: { period: AllocationPeriod }) {
return (
<div
aria-label={`${period.label} capacity allocation is ${period.allocation}`}
className="flex h-7 w-full items-stretch justify-between"
role="img"
>
{segments.map((segment) => (
<span
aria-hidden="true"
key={segment}
className={cn(
"h-full w-1 shrink-0 rounded-full",
segment < period.filledSegments ? "bg-success" : "bg-muted"
)}
/>
))}
</div>
)
}
function MemberStack() {
return (
<div className="flex items-center gap-2">
<AvatarGroup className="-space-x-2">
{allocationMembers.map((member) => (
<Avatar key={member.name} className="size-6">
{member.avatar ? (
<AvatarImage src={member.avatar} alt={member.name} />
) : null}
<AvatarFallback className="bg-background text-xs font-medium">
{member.initials}
</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
<span className="text-muted-foreground text-xs whitespace-nowrap">
{allocationMemberCount} Members
</span>
</div>
)
}
function AllocationChart() {
return (
<Frame className="@container h-full w-full">
<FramePanel>
<Tabs
defaultValue={allocationPeriods[0].value}
className="h-full w-full min-w-0 gap-4"
>
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1.5">
<h2 className="text-sm font-medium">Capacity Allocation</h2>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className="text-muted-foreground/70 hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex shrink-0 rounded-full p-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
aria-label="Capacity Allocation info"
/>
}
>
<InfoIcon className="size-3.5" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent
side="top"
className="max-w-56 px-2.5 py-1.5 text-xs leading-5"
>
Fulfillment capacity by selected period.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<TabsList>
{allocationPeriods.map((period) => (
<TabsTrigger key={period.value} value={period.value}>
{period.label}
</TabsTrigger>
))}
</TabsList>
</div>
{allocationPeriods.map((period) => (
<TabsContent
key={period.value}
value={period.value}
className="mt-0"
>
<div className="flex flex-col gap-4">
{/* Metric */}
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="text-[26px] font-medium">
{period.allocation}
</span>
<span className="text-success text-xs font-medium">
{period.delta}
</span>
<span className="text-muted-foreground/70 text-xs">
{period.comparison}
</span>
</div>
{/* Chart */}
<AllocationMeter period={period} />
{/* Footer */}
<div className="mt-1 flex flex-wrap items-center justify-between gap-3">
<p>
<span className="text-muted-foreground/70 text-xs">
Queued Orders:
</span>{" "}
<span className="text-sm font-medium">
{period.exposure}
</span>
</p>
<MemberStack />
</div>
</div>
</TabsContent>
))}
</Tabs>
</FramePanel>
</Frame>
)
}
type DonutSlice =
| InflowFund
| {
key: "reserve"
name: string
amount: string
share: number
color: string
fill: string
}
function getDonutData(period: InflowPeriod) {
const trackedShare = period.funds.reduce(
(total, fund) => total + fund.share,
0
)
const reserveShare = Math.max(100 - trackedShare, 0)
return [
...period.funds,
{
key: "reserve",
name: "Reserve Capacity",
amount: "",
share: reserveShare,
color: "var(--muted)",
fill: "var(--color-reserve)",
},
] satisfies DonutSlice[]
}
function ChartTooltipFormatter(item: unknown) {
const fund = item as DonutSlice
const value = fund.key === "reserve" ? `${fund.share}%` : fund.amount
return (
<div className="flex min-w-40 items-center justify-between gap-6">
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: fund.color }}
/>
<span className="text-muted-foreground truncate">{fund.name}</span>
</div>
<span className="text-foreground font-medium tabular-nums">{value}</span>
</div>
)
}
function InfoTooltip() {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="About Decision Flow"
className="text-muted-foreground/70 -my-1"
size="icon-sm"
type="button"
variant="ghost"
>
<InfoIcon aria-hidden="true" className="text-sm" data-icon="inline-start" />
</Button>
}
/>
<TooltipContent side="top" sideOffset={8}>
<p>Tracked decisions entering fulfillment lanes.</p>
</TooltipContent>
</Tooltip>
)
}
function InflowDonut({ period }: { period: InflowPeriod }) {
const chartData = getDonutData(period)
return (
<div className="dashboard-1-flow-reveal-up relative size-[8.25rem] shrink-0">
<ChartContainer
aria-label={`Decision Flow: ${period.total} total for ${period.label}`}
className="aspect-square size-[8.25rem]"
config={inflowChartConfig}
initialDimension={{ width: 132, height: 132 }}
>
<PieChart margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
<ChartTooltip
cursor={false}
wrapperStyle={{ zIndex: 30 }}
content={
<ChartTooltipContent
hideLabel
hideIndicator
formatter={(_value, _name, item) =>
ChartTooltipFormatter(item.payload)
}
/>
}
/>
<Pie
data={chartData}
dataKey="share"
endAngle={-230}
innerRadius={47}
isAnimationActive={false}
nameKey="name"
outerRadius={62}
paddingAngle={1}
cornerRadius={3}
startAngle={130}
stroke="var(--background)"
strokeWidth={2}
>
{chartData.map((item) => (
<Cell key={item.key} fill={item.fill} />
))}
</Pie>
</PieChart>
</ChartContainer>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center"
>
<div className="bg-background/90 border-border/70 flex size-[5.25rem] flex-col items-center justify-center rounded-full border border-dashed">
<span className="text-muted-foreground/70 text-xs">Flow</span>
<span className="mt-0.5 text-sm font-semibold">{period.total}</span>
</div>
</div>
</div>
)
}
function InflowLegend({ period }: { period: InflowPeriod }) {
return (
<ul className="flex min-w-0 flex-1 flex-col">
{period.funds.map((fund, index) => (
<li key={fund.key}>
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2.5">
<div className="flex min-w-0 items-center gap-2.5">
<span
aria-hidden="true"
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
style={{ backgroundColor: fund.color }}
/>
<span className="text-sm font-medium">{fund.name}</span>
</div>
<span className="text-sm font-medium">{fund.amount}</span>
<span className="text-muted-foreground/70 w-8 text-right text-xs">
{fund.share}%
</span>
</div>
{index < period.funds.length - 1 ? (
<Separator className="w-auto" />
) : null}
</li>
))}
</ul>
)
}
function InflowPeriodPanel({ period }: { period: InflowPeriod }) {
return (
<div className="grid gap-6 @sm:grid-cols-[8.25rem_minmax(0,1fr)] @sm:items-center">
<InflowDonut period={period} />
<InflowLegend period={period} />
</div>
)
}
function InflowChart() {
return (
<TooltipProvider delay={150}>
<style>{CHART_REVEAL_STYLE}</style>
<Frame className="@container h-full w-full">
<FramePanel className="ps-3.5! pe-5! pt-5! pb-3.5!">
<Tabs defaultValue="week" className="gap-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-0.5 ps-1.5">
<h2 className="text-sm font-medium">Decision Flow</h2>
<InfoTooltip />
</div>
<TabsList className="w-full @sm:w-auto">
{inflowPeriods.map((period) => (
<TabsTrigger key={period.value} value={period.value}>
{period.label}
</TabsTrigger>
))}
</TabsList>
</div>
{/* Content */}
{inflowPeriods.map((period) => (
<TabsContent
key={period.value}
value={period.value}
className="mt-0"
>
<InflowPeriodPanel period={period} />
</TabsContent>
))}
</Tabs>
</FramePanel>
</Frame>
</TooltipProvider>
)
}
export function Chart() {
return (
<div className="@container grid h-full w-full min-w-0 auto-rows-fr gap-3">
<AllocationChart />
<InflowChart />
</div>
)
}
@@ -1,64 +0,0 @@
import { Frame, FramePanel } from "@/components/reui/frame"
import { cn } from "@evobgp/ui/lib/utils"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import { FULFILLMENT_CARDS, type FulfillmentCard } from "./data"
function CardItem({ card }: { card: FulfillmentCard }) {
return (
<FramePanel>
{/* Heading */}
<div className="flex items-center gap-2.5">
<Item
className={cn(
"p-0",
"border-background flex size-10 items-center justify-center border-2 [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)] shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white",
card.iconBg
)}
>
<ItemMedia variant="icon" className="size-auto">
{card.icon}
</ItemMedia>
</Item>
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm leading-tight">
{card.typeLabel}
</p>
<h3 className="text-sm leading-tight font-medium">{card.title}</h3>
</div>
</div>
<div className="mt-5 space-y-1.5">
<p className="text-muted-foreground text-sm leading-tight">
{card.metricLabel}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xl font-medium tracking-tight">
{card.balance}
</span>
<span
className={cn(
"text-sm font-medium",
card.change.positive ? "text-teal-600" : "text-rose-600"
)}
>
{card.change.percent} ({card.change.amount})
</span>
</div>
</div>
</FramePanel>
)
}
export function Chart() {
return (
<Frame className="@container w-full">
{/* Grid */}
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
{FULFILLMENT_CARDS.map((card) => (
<CardItem key={card.title} card={card} />
))}
</div>
</Frame>
)
}
@@ -1,155 +0,0 @@
import { Badge } from "@/components/reui/badge"
import {
Frame,
FrameFooter,
FramePanel,
} from "@/components/reui/frame"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import { Progress } from "@evobgp/ui/components/progress"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Separator } from "@evobgp/ui/components/separator"
import {
PERFORMANCE_RANGE_OPTIONS,
SHIFT_ACTIVITY,
SHIFT_PERFORMANCE,
SHIFT_PIPELINE_PROGRESS,
} from "./data"
import { TrendingUp, TrendingDown, CircleCheckIcon } from "lucide-react"
export function InvestorCard() {
return (
<Frame className="h-full w-full">
{/* Content */}
<FramePanel>
<div className="mb-6 flex items-start justify-between gap-3">
<div className="flex flex-col gap-px">
<h3 className="text-base font-semibold">Shift Performance</h3>
</div>
<div className="flex items-center gap-2">
<Select defaultValue="today" items={PERFORMANCE_RANGE_OPTIONS}>
<SelectTrigger className="h-8! w-28">
<SelectValue />
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="w-28"
>
{PERFORMANCE_RANGE_OPTIONS.map((range) => (
<SelectItem key={range.value} value={range.value}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-5">
<div className="grid grid-cols-3 gap-2">
{SHIFT_PERFORMANCE.map((item) => (
<div
className="flex flex-col items-start justify-start"
key={item.label}
>
<div className="text-foreground text-xl font-bold">
{item.value}
</div>
<div className="text-muted-foreground mb-1 text-xs font-medium">
{item.label}
</div>
<span
className={cn(
"flex items-center gap-0.5 text-xs font-semibold [&_svg]:h-3 [&_svg]:w-3",
item.trend === "positive"
? "text-emerald-500"
: "text-destructive"
)}
>
{item.trend === "positive" ? (
<TrendingUp aria-hidden="true" />
) : (
<TrendingDown aria-hidden="true" />
)}
{item.delta}
</span>
</div>
))}
</div>
<Separator />
<div>
<div className="mb-2.5 flex items-center justify-between">
<span className="text-foreground text-sm font-medium">
Pipeline Progress
</span>
<span className="text-foreground text-xs font-semibold">
{SHIFT_PIPELINE_PROGRESS}%
</span>
</div>
<Progress
value={SHIFT_PIPELINE_PROGRESS}
className="h-1! **:data-[slot=progress-track]:h-1"
/>
</div>
<Separator />
<div>
<div className="text-foreground mb-2.5 text-sm font-medium">
Recent Activity
</div>
<ul className="space-y-2">
{SHIFT_ACTIVITY.map((activity) => (
<li
key={activity.id}
className="flex items-center justify-between gap-2.5 text-sm"
>
<span className="flex min-w-0 items-center gap-2">
<CircleCheckIcon className={cn(
"h-3.5 w-3.5 shrink-0",
activity.tone === "success" && "text-emerald-500",
activity.tone === "info" && "text-sky-500",
activity.tone === "warning" && "text-amber-500"
)} aria-hidden="true" />
<span className="text-foreground truncate text-xs">
{activity.title}
</span>
</span>
<Badge
variant={
activity.tone === "success"
? "success-light"
: activity.tone === "info"
? "info-light"
: "warning-light"
}
className="shrink-0"
>
{activity.status}
</Badge>
</li>
))}
</ul>
</div>
</div>
</FramePanel>
{/* Footer */}
<FrameFooter className="flex-row items-center gap-2.5 p-2!">
<Button variant="outline" className="flex-1">
Schedule
</Button>
<Button className="flex-1">Full Report</Button>
</FrameFooter>
</Frame>
)
}
@@ -1,33 +0,0 @@
import { Chart as CapacityChart } from "./capacity-chart"
import { Chart as ChartCards } from "./chart-cards"
import { InvestorCard as CommanderCard } from "./commander-card"
import { ExceptionGrid } from "./exception-grid"
import { Navbar } from "./navbar"
export function Dashboard() {
return (
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-2">
<Navbar />
<section aria-label="Fulfillment metrics">
<ChartCards />
</section>
<section
aria-label="Fulfillment operations"
className="grid min-w-0 items-stretch gap-3 @5xl:grid-cols-2"
>
<div className="flex min-w-0">
<CommanderCard />
</div>
<div className="flex min-w-0">
<CapacityChart />
</div>
</section>
<section aria-label="Fulfillment exception queue">
<ExceptionGrid />
</section>
</div>
)
}
@@ -1,676 +0,0 @@
import { type ReactNode } from "react"
import { type BadgeProps } from "@/components/reui/badge"
import { type ChartConfig } from "@evobgp/ui/components/chart"
import { PackageIcon, TruckIcon, TriangleAlertIcon, BotIcon } from "lucide-react"
export type FulfillmentStatus = "On Time" | "At Risk" | "Delayed" | "Blocked"
export type AutomationLevel = "Autopilot" | "Copilot" | "Manual"
export interface TeamMember {
name: string
initials: string
avatar: string
role: string
}
export interface FulfillmentException {
id: string
reference: string
customer: string
email: string
avatar: string
initials: string
lane: string
facility: string
stage: string
promise: string
slaMinutes: number
automation: AutomationLevel
owner: string
units: number
value: number
risk: string
status: FulfillmentStatus
}
export const STATUS_ORDER: FulfillmentStatus[] = [
"On Time",
"At Risk",
"Delayed",
"Blocked",
]
export const STATUS_BADGE_VARIANT: Record<
FulfillmentStatus,
BadgeProps["variant"]
> = {
"On Time": "success-outline",
"At Risk": "warning-outline",
Delayed: "info-outline",
Blocked: "destructive-outline",
}
export const AUTOMATION_BADGE_VARIANT: Record<
AutomationLevel,
BadgeProps["variant"]
> = {
Autopilot: "success-light",
Copilot: "info-light",
Manual: "warning-light",
}
export const NAV_MEMBERS: TeamMember[] = [
{
name: "Maya Singh",
initials: "MS",
avatar:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
role: "Fulfillment lead",
},
{
name: "Leo Martins",
initials: "LM",
avatar:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
role: "Automation owner",
},
{
name: "Nora Albright",
initials: "NA",
avatar:
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
role: "Capacity planner",
},
]
export const TEAM_MEMBERS = NAV_MEMBERS.map((member) => ({
src: member.avatar,
initials: member.initials,
name: member.name,
}))
export const TEAM_EXTRA_COUNT = 11
export interface FulfillmentCardChange {
positive: boolean
percent: string
amount: string
}
export interface FulfillmentCard {
typeLabel: string
title: string
metricLabel: string
balance: string
change: FulfillmentCardChange
icon: ReactNode
iconBg: string
}
export const FULFILLMENT_CARDS: FulfillmentCard[] = [
{
typeLabel: "Outbound",
title: "Orders Ready",
metricLabel: "Ready Volume",
balance: "18,420",
change: {
positive: true,
percent: "+11.8%",
amount: "1,946",
},
iconBg: "bg-neutral-950",
icon: (
<PackageIcon aria-hidden="true" />
),
},
{
typeLabel: "Promise",
title: "Same-Day SLA",
metricLabel: "Service Level",
balance: "94.8%",
change: {
positive: true,
percent: "+1.2 pts",
amount: "shift",
},
iconBg: "bg-indigo-600",
icon: (
<TruckIcon aria-hidden="true" />
),
},
{
typeLabel: "Inventory",
title: "Stock Risk",
metricLabel: "Blocked SKUs",
balance: "31",
change: {
positive: true,
percent: "13 fewer",
amount: "since 06:00",
},
iconBg: "bg-amber-400",
icon: (
<TriangleAlertIcon aria-hidden="true" />
),
},
{
typeLabel: "Policy",
title: "AI Autopilot",
metricLabel: "Auto Resolved",
balance: "71.6%",
change: {
positive: true,
percent: "+8.4 pts",
amount: "policy",
},
iconBg: "bg-cyan-600",
icon: (
<BotIcon aria-hidden="true" />
),
},
]
export type AllocationPeriod = {
value: "week" | "month" | "year"
label: string
allocation: string
delta: string
comparison: string
exposure: string
filledSegments: number
}
export type AllocationMember = {
name: string
initials: string
avatar?: string
}
export const SEGMENT_COUNT = 56
export const allocationMemberCount = 6
export const allocationPeriods: AllocationPeriod[] = [
{
value: "week",
label: "Week",
allocation: "86%",
delta: "+5.8%",
comparison: "vs labor plan",
exposure: "3,840 orders",
filledSegments: 48,
},
{
value: "month",
label: "Month",
allocation: "79%",
delta: "+2.4%",
comparison: "vs prior month",
exposure: "18 priority lanes",
filledSegments: 44,
},
{
value: "year",
label: "Year",
allocation: "74%",
delta: "+9.2%",
comparison: "automation lift",
exposure: "6 facilities",
filledSegments: 41,
},
]
export const allocationMembers: AllocationMember[] = TEAM_MEMBERS.map(
(member) => ({
name: member.name,
initials: member.initials,
avatar: member.src,
})
)
export type PerformanceTrend = "positive" | "negative"
export type ActivityTone = "success" | "info" | "warning"
export interface PerformanceMetric {
label: string
value: string
trend: PerformanceTrend
delta: string
}
export interface ShiftActivity {
id: string
title: string
time: string
status: string
tone: ActivityTone
}
export const PERFORMANCE_RANGE_OPTIONS = [
{ label: "Today", value: "today" },
{ label: "Week", value: "week" },
{ label: "Month", value: "month" },
]
export const SHIFT_PERFORMANCE: PerformanceMetric[] = [
{
label: "Orders Cleared",
value: "18.4k",
trend: "positive",
delta: "+11.8%",
},
{
label: "SLA Recovery",
value: "94.8%",
trend: "positive",
delta: "+1.2 pts",
},
{
label: "Risk Exposure",
value: "$128k",
trend: "negative",
delta: "-9.4%",
},
]
export const SHIFT_PIPELINE_PROGRESS = 76
export const SHIFT_ACTIVITY: ShiftActivity[] = [
{
id: "wave-release",
title: "Released priority wave to dock B",
time: "4 min ago",
status: "Cleared",
tone: "success",
},
{
id: "carrier-reprice",
title: "Carrier mix repriced for zone 6",
time: "12 min ago",
status: "Review",
tone: "info",
},
{
id: "inventory-hold",
title: "Inventory hold isolated to 3 SKUs",
time: "23 min ago",
status: "Watch",
tone: "warning",
},
]
export type InflowFundKey = "autopilot" | "copilot" | "manual" | "reserve"
export interface InflowFund {
key: Exclude<InflowFundKey, "reserve">
name: string
amount: string
share: number
color: string
fill: string
}
export interface InflowPeriod {
value: "week" | "month" | "year"
label: string
total: string
headline: string
description: string
delta: string
funds: InflowFund[]
}
const inflowAutopilotColor = "oklch(0.62 0.19 149)"
const inflowCopilotColor = "oklch(0.58 0.18 257)"
const inflowManualColor = "oklch(0.72 0.16 78)"
export const inflowChartConfig = {
flow: {
label: "Flow",
},
autopilot: {
label: "Autopilot",
color: inflowAutopilotColor,
},
copilot: {
label: "Copilot",
color: inflowCopilotColor,
},
manual: {
label: "Manual",
color: inflowManualColor,
},
reserve: {
label: "Reserve",
color: "oklch(0.7 0.04 260)",
},
} satisfies ChartConfig
export const inflowPeriods: InflowPeriod[] = [
{
value: "week",
label: "Week",
total: "18.4k",
headline: "Exception Flow",
description: "Orders entering decision lanes",
delta: "+6.2%",
funds: [
{
key: "autopilot",
name: "Autopilot",
amount: "9.1k",
share: 49.5,
color: inflowAutopilotColor,
fill: "var(--color-autopilot)",
},
{
key: "copilot",
name: "Copilot",
amount: "5.2k",
share: 28.3,
color: inflowCopilotColor,
fill: "var(--color-copilot)",
},
{
key: "manual",
name: "Manual",
amount: "2.8k",
share: 15.2,
color: inflowManualColor,
fill: "var(--color-manual)",
},
],
},
{
value: "month",
label: "Month",
total: "76.8k",
headline: "Resolved Flow",
description: "Completed decisions this month",
delta: "+14.8%",
funds: [
{
key: "autopilot",
name: "Autopilot",
amount: "41.6k",
share: 54.2,
color: inflowAutopilotColor,
fill: "var(--color-autopilot)",
},
{
key: "copilot",
name: "Copilot",
amount: "20.3k",
share: 26.4,
color: inflowCopilotColor,
fill: "var(--color-copilot)",
},
{
key: "manual",
name: "Manual",
amount: "9.8k",
share: 12.8,
color: inflowManualColor,
fill: "var(--color-manual)",
},
],
},
{
value: "year",
label: "Year",
total: "812k",
headline: "Network Flow",
description: "Decisions across six facilities",
delta: "+21.5%",
funds: [
{
key: "autopilot",
name: "Autopilot",
amount: "428k",
share: 52.7,
color: inflowAutopilotColor,
fill: "var(--color-autopilot)",
},
{
key: "copilot",
name: "Copilot",
amount: "224k",
share: 27.6,
color: inflowCopilotColor,
fill: "var(--color-copilot)",
},
{
key: "manual",
name: "Manual",
amount: "103k",
share: 12.7,
color: inflowManualColor,
fill: "var(--color-manual)",
},
],
},
]
export const FULFILLMENT_ROWS: FulfillmentException[] = [
{
id: "row-1001",
reference: "NSC-84721",
customer: "Avery Outdoor",
email: "ops@averyoutdoor.example",
avatar:
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&dpr=2&q=80",
initials: "AO",
lane: "Chicago to Austin",
facility: "ORD-2",
stage: "Carrier tender",
promise: "Today 18:00",
slaMinutes: 42,
automation: "Copilot",
owner: "Maya Singh",
units: 480,
value: 38240,
risk: "Carrier capacity is tight after midday cutoff",
status: "At Risk",
},
{
id: "row-1002",
reference: "NSC-84734",
customer: "Field & Frame",
email: "priority@fieldframe.example",
avatar:
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80",
initials: "FF",
lane: "Dallas to Phoenix",
facility: "DFW-1",
stage: "Pick wave",
promise: "Today 16:30",
slaMinutes: 88,
automation: "Autopilot",
owner: "Leo Martins",
units: 310,
value: 21480,
risk: "Wave optimized by carton density",
status: "On Time",
},
{
id: "row-1003",
reference: "NSC-84755",
customer: "MetroFit Labs",
email: "ops@metrofit.example",
avatar:
"https://images.unsplash.com/photo-1519345182560-3f2917c472ef?w=96&h=96&dpr=2&q=80",
initials: "ML",
lane: "Newark to Boston",
facility: "EWR-3",
stage: "Inventory hold",
promise: "Today 15:15",
slaMinutes: -24,
automation: "Manual",
owner: "Nora Albright",
units: 126,
value: 18760,
risk: "Lot trace requires human release",
status: "Blocked",
},
{
id: "row-1004",
reference: "NSC-84763",
customer: "Northline Studio",
email: "returns@northline.example",
avatar:
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
initials: "NS",
lane: "Los Angeles to Seattle",
facility: "LAX-4",
stage: "Packing",
promise: "Today 19:45",
slaMinutes: 114,
automation: "Autopilot",
owner: "Leo Martins",
units: 840,
value: 52210,
risk: "Packing line is running above plan",
status: "On Time",
},
{
id: "row-1005",
reference: "NSC-84801",
customer: "Urban Pantry",
email: "supply@urbanpantry.example",
avatar:
"https://images.unsplash.com/photo-1531427186611-ecfd6d936c79?w=96&h=96&dpr=2&q=80",
initials: "UP",
lane: "Atlanta to Miami",
facility: "ATL-2",
stage: "Cold chain",
promise: "Today 17:00",
slaMinutes: 9,
automation: "Copilot",
owner: "Maya Singh",
units: 212,
value: 30440,
risk: "Reefer handoff needs confirmation",
status: "Delayed",
},
{
id: "row-1006",
reference: "NSC-84819",
customer: "Glow Market",
email: "vip@glowmarket.example",
avatar:
"https://images.unsplash.com/photo-1489424731084-a5d8b219a5bb?w=96&h=96&dpr=2&q=80",
initials: "GM",
lane: "Las Vegas to Denver",
facility: "LAS-1",
stage: "Labeling",
promise: "Tomorrow 09:20",
slaMinutes: 312,
automation: "Autopilot",
owner: "Nora Albright",
units: 94,
value: 10920,
risk: "No current risk",
status: "On Time",
},
{
id: "row-1007",
reference: "NSC-84827",
customer: "Ridge Supply",
email: "buyers@ridgesupply.example",
avatar:
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
initials: "RS",
lane: "Portland to San Jose",
facility: "PDX-1",
stage: "Split shipment",
promise: "Today 20:00",
slaMinutes: 36,
automation: "Copilot",
owner: "Maya Singh",
units: 176,
value: 14680,
risk: "Two SKUs short at primary node",
status: "At Risk",
},
{
id: "row-1008",
reference: "NSC-84842",
customer: "Casa Verde",
email: "storeops@casaverde.example",
avatar:
"https://images.unsplash.com/photo-1544725176-7c40e5a71c5e?w=96&h=96&dpr=2&q=80",
initials: "CV",
lane: "Nashville to Charlotte",
facility: "BNA-2",
stage: "Dock queue",
promise: "Today 14:30",
slaMinutes: -51,
automation: "Manual",
owner: "Nora Albright",
units: 265,
value: 22750,
risk: "Outbound door is constrained",
status: "Delayed",
},
{
id: "row-1009",
reference: "NSC-84864",
customer: "Beacon Cycle",
email: "logistics@beaconcycle.example",
avatar:
"https://images.unsplash.com/photo-1552058544-f2b08422138a?w=96&h=96&dpr=2&q=80",
initials: "BC",
lane: "Columbus to Pittsburgh",
facility: "CMH-1",
stage: "Fraud review",
promise: "Tomorrow 11:45",
slaMinutes: 510,
automation: "Manual",
owner: "Maya Singh",
units: 58,
value: 8920,
risk: "Payment review blocks release",
status: "Blocked",
},
{
id: "row-1010",
reference: "NSC-84888",
customer: "Aster Goods",
email: "ops@astergoods.example",
avatar:
"https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?w=96&h=96&dpr=2&q=80",
initials: "AG",
lane: "Reno to Salt Lake City",
facility: "RNO-1",
stage: "Manifest",
promise: "Today 22:15",
slaMinutes: 177,
automation: "Autopilot",
owner: "Leo Martins",
units: 390,
value: 19340,
risk: "Manifest is ready for carrier scan",
status: "On Time",
},
]
export function fulfillmentSearchBlob(row: FulfillmentException): string {
return [
row.reference,
row.customer,
row.email,
row.lane,
row.facility,
row.stage,
row.promise,
row.automation,
row.owner,
row.risk,
row.status,
String(row.units),
String(row.value),
]
.filter(Boolean)
.join(" ")
.toLowerCase()
}
@@ -1,520 +0,0 @@
import { memo } from "react"
import { Badge } from "@/components/reui/badge"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import {
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from "@/components/reui/data-grid/data-grid-table"
import { type ColumnDef, type Row } from "@tanstack/react-table"
import { toast } from "sonner"
import { cn } from "@evobgp/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@evobgp/ui/components/avatar"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import { Item, ItemMedia } from "@evobgp/ui/components/item"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@evobgp/ui/components/tooltip"
import {
AUTOMATION_BADGE_VARIANT,
STATUS_BADGE_VARIANT,
type AutomationLevel,
type FulfillmentException,
type FulfillmentStatus,
} from "./data"
import { PackageIcon, InfoIcon, MoreHorizontalIcon, EyeIcon, BellIcon, CopyIcon, TriangleAlertIcon } from "lucide-react"
const currencyCompact = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
})
const numberCompact = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0,
})
const availabilityColor: Record<FulfillmentStatus, string> = {
"On Time": "bg-success",
"At Risk": "bg-warning",
Delayed: "bg-info",
Blocked: "bg-destructive",
}
const stageProgress: Record<string, number> = {
"Carrier tender": 72,
"Pick wave": 64,
"Inventory hold": 28,
Packing: 82,
"Cold chain": 48,
Labeling: 76,
"Split shipment": 39,
"Dock queue": 31,
"Fraud review": 24,
Manifest: 90,
}
function DotSeparator() {
return (
<span
aria-hidden="true"
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
/>
)
}
export const StatusBadge = memo(function StatusBadge({
status,
}: {
status: FulfillmentStatus
}) {
return (
<Badge variant={STATUS_BADGE_VARIANT[status]} className="gap-1.5">
<span
aria-hidden="true"
className={cn("size-1.5 rounded-full", availabilityColor[status])}
/>
{status}
</Badge>
)
})
function AutomationBadge({ level }: { level: AutomationLevel }) {
return <Badge variant={AUTOMATION_BADGE_VARIANT[level]}>{level}</Badge>
}
const ReferenceCell = memo(function ReferenceCell({
row,
}: {
row: Row<FulfillmentException>
}) {
const order = row.original
return (
<div className="flex min-w-0 flex-col gap-1">
<a
href="#"
className="text-primary truncate text-sm font-medium underline-offset-2 transition-colors hover:underline"
aria-label={`View order ${order.reference}`}
>
{order.reference}
</a>
<div className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs">
<span className="shrink-0">{order.facility}</span>
<DotSeparator />
<span className="truncate">{order.owner}</span>
</div>
</div>
)
})
const CustomerCell = memo(function CustomerCell({
row,
}: {
row: Row<FulfillmentException>
}) {
const order = row.original
return (
<div className="flex min-w-0 items-center gap-2">
<div className="relative shrink-0">
<Avatar className="size-8">
<AvatarImage src={order.avatar} alt={order.customer} />
<AvatarFallback>{order.initials}</AvatarFallback>
</Avatar>
<span
className={cn(
"ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2",
availabilityColor[order.status]
)}
aria-hidden="true"
/>
</div>
<div className="min-w-0">
<a
href="#"
className="text-foreground hover:text-primary line-clamp-1 font-medium underline-offset-2 transition-colors hover:underline"
aria-label={`View customer ${order.customer}`}
>
{order.customer}
</a>
<div
className="text-muted-foreground line-clamp-1 text-xs"
title={order.email}
>
{order.email}
</div>
</div>
</div>
)
})
const StageCell = memo(function StageCell({
row,
}: {
row: Row<FulfillmentException>
}) {
const order = row.original
const progress = stageProgress[order.stage] ?? 50
return (
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex min-w-0 items-center gap-1.5">
<Item render={<span />} className="w-auto shrink-0 border-0 p-0">
<ItemMedia variant="icon" className="text-muted-foreground size-auto">
<PackageIcon className="size-4" aria-hidden="true" />
</ItemMedia>
</Item>
<span className="text-foreground min-w-0 truncate font-medium">
{order.stage}
</span>
</div>
<div className="flex min-w-0 items-center gap-2">
<span className="bg-muted block h-1.5 min-w-16 flex-1 overflow-hidden rounded-full">
<span
className={cn(
"block h-full rounded-full",
availabilityColor[order.status]
)}
style={{ width: `${progress}%` }}
/>
</span>
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{progress}%
</span>
</div>
</div>
)
})
const LaneCell = memo(function LaneCell({
row,
}: {
row: Row<FulfillmentException>
}) {
const order = row.original
return (
<div className="flex max-w-full min-w-0 flex-col gap-0.5">
<span
className="text-foreground block max-w-full min-w-0 truncate font-medium"
title={order.lane}
>
{order.lane}
</span>
<span
className="text-muted-foreground block max-w-full min-w-0 truncate text-xs"
title={order.facility}
>
{order.facility}
</span>
</div>
)
})
const ValueCell = memo(function ValueCell({
row,
}: {
row: Row<FulfillmentException>
}) {
const valueHint =
row.original.value >= 30000 ? "Priority lane" : "Standard lane"
return (
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-foreground font-medium tabular-nums">
{currencyCompact.format(row.original.value)}
</span>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex size-5 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-offset-2"
aria-label={`Value hint for ${row.original.reference}: ${valueHint}`}
/>
}
>
<InfoIcon className="size-3.5" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-48 p-2.5 text-xs leading-5">
<div className="flex flex-col">
<span>{valueHint}</span>
<span className="text-background/80">
{numberCompact.format(row.original.units)} units
</span>
</div>
</TooltipContent>
</Tooltip>
</div>
)
})
function StateCell({ row }: { row: Row<FulfillmentException> }) {
const sla = row.original.slaMinutes
const slaHint =
sla < 0
? `${Math.abs(sla)} min overdue`
: sla <= 45
? `${sla} min buffer`
: `Due ${row.original.promise}`
return (
<div className="flex min-w-0 flex-col items-start gap-1">
<StatusBadge status={row.original.status} />
<span className="text-muted-foreground max-w-full truncate text-xs">
{slaHint}
</span>
</div>
)
}
function RiskCell({ row }: { row: Row<FulfillmentException> }) {
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex items-center gap-1.5 rounded-full focus-visible:ring-2 focus-visible:ring-offset-2"
aria-label={`Risk note for ${row.original.reference}: ${row.original.risk}`}
/>
}
>
<InfoIcon className="size-3.5" aria-hidden="true" />
<span className="max-w-32 truncate text-xs">{row.original.risk}</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs p-3 text-xs leading-5">
{row.original.risk}
</TooltipContent>
</Tooltip>
)
}
function ActionsCell({ row }: { row: Row<FulfillmentException> }) {
const copyReference = async () => {
await navigator.clipboard?.writeText(row.original.reference)
toast.success("Reference copied", {
description: row.original.reference,
})
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label={`Actions for ${row.original.reference}`}
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() =>
toast.info("Opening order", {
description: row.original.reference,
})
}
>
<EyeIcon className="size-4" aria-hidden="true" />
View order
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.info("Owner notified", {
description: row.original.owner,
})
}
>
<BellIcon className="size-4" aria-hidden="true" />
Notify owner
</DropdownMenuItem>
<DropdownMenuItem onClick={copyReference}>
<CopyIcon className="size-4" aria-hidden="true" />
Copy reference
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() =>
toast.warning("Escalation staged", {
description: "Connect this action to your incident workflow.",
})
}
>
<TriangleAlertIcon className="size-4" aria-hidden="true" />
Escalate
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export const columns: ColumnDef<FulfillmentException>[] = [
{
accessorKey: "id",
id: "id",
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
enableSorting: false,
size: 35,
enableResizing: false,
enableHiding: false,
meta: {
headerClassName: "ps-4!",
cellClassName: "ps-4!",
},
},
{
accessorKey: "reference",
id: "reference",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ReferenceCell row={row} />,
size: 138,
enableSorting: true,
enableHiding: false,
enableResizing: true,
meta: {
headerTitle: "Order",
},
},
{
accessorKey: "customer",
id: "customer",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <CustomerCell row={row} />,
size: 210,
enableSorting: true,
enableHiding: false,
enableResizing: true,
minSize: 190,
meta: {
headerTitle: "Customer",
autoSize: true,
},
},
{
accessorKey: "lane",
id: "lane",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <LaneCell row={row} />,
size: 165,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Lane",
},
},
{
accessorKey: "stage",
id: "stage",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <StageCell row={row} />,
size: 160,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Stage",
},
},
{
accessorKey: "automation",
id: "automation",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <AutomationBadge level={row.original.automation} />,
size: 112,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Automation",
},
},
{
accessorKey: "value",
id: "value",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ValueCell row={row} />,
size: 120,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Value",
},
},
{
accessorKey: "risk",
id: "risk",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <RiskCell row={row} />,
size: 170,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Risk",
},
},
{
accessorKey: "status",
id: "status",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <StateCell row={row} />,
size: 142,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "State",
},
},
{
id: "actions",
header: "",
cell: ({ row }) => <ActionsCell row={row} />,
size: 46,
enableSorting: false,
enableHiding: false,
enableResizing: false,
},
]
@@ -1,369 +0,0 @@
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { DataGrid as ReuiDataGrid } from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type PaginationState,
type RowSelectionState,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { Button } from "@evobgp/ui/components/button"
import { Checkbox } from "@evobgp/ui/components/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evobgp/ui/components/input-group"
import { Label } from "@evobgp/ui/components/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { Separator } from "@evobgp/ui/components/separator"
import { TooltipProvider } from "@evobgp/ui/components/tooltip"
import {
FULFILLMENT_ROWS,
fulfillmentSearchBlob,
STATUS_ORDER,
type FulfillmentStatus,
} from "./data"
import { columns, StatusBadge } from "./exception-columns"
import { SearchIcon, XIcon, FilterIcon, MoreHorizontalIcon, FileDownIcon, RefreshCwIcon, SettingsIcon, PlusIcon } from "lucide-react"
interface ToolbarProps {
searchQuery: string
onSearchChange: (value: string) => void
selectedStatuses: FulfillmentStatus[]
onStatusChange: (checked: boolean, status: FulfillmentStatus) => void
onClearFilters: () => void
hasActiveFilters: boolean
statusCounts: Record<string, number>
}
function Toolbar({
searchQuery,
onSearchChange,
selectedStatuses,
onStatusChange,
onClearFilters,
hasActiveFilters,
statusCounts,
}: ToolbarProps) {
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<InputGroup className="w-full min-w-52 sm:w-60">
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search orders..."
aria-label="Search orders"
value={searchQuery}
onChange={(event) => onSearchChange(event.target.value)}
/>
{searchQuery.length > 0 && (
<InputGroupAddon align="inline-end">
<InputGroupButton
aria-label="Clear search"
size="icon-xs"
onClick={() => onSearchChange("")}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<Popover>
<PopoverTrigger
render={
<Button variant="outline" aria-label="Filter by order status">
<FilterIcon aria-hidden="true" />
Status
{selectedStatuses.length > 0 && (
<Badge variant="info-outline">
{selectedStatuses.length}
</Badge>
)}
</Button>
}
/>
<PopoverContent
align="start"
className="flex w-48 flex-col gap-2.5 p-3"
>
<span className="text-muted-foreground text-xs font-medium">
Filter by status
</span>
{STATUS_ORDER.map((status) => (
<div key={status} className="flex items-center gap-2.5">
<Checkbox
id={`status-${status.toLowerCase().replace(/\s+/g, "-")}`}
checked={selectedStatuses.includes(status)}
onCheckedChange={(checked) =>
onStatusChange(checked === true, status)
}
/>
<Label
htmlFor={`status-${status.toLowerCase().replace(/\s+/g, "-")}`}
className="flex min-w-0 flex-1 cursor-pointer items-center justify-between gap-2 font-normal"
>
<StatusBadge status={status} />
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{statusCounts[status] ?? 0}
</span>
</Label>
</div>
))}
</PopoverContent>
</Popover>
{hasActiveFilters && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={onClearFilters}
>
Clear filters
</Button>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" aria-label="Exception queue actions">
<MoreHorizontalIcon aria-hidden="true" />
Actions
</Button>
}
/>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() =>
toast.success("Export ready", {
description: "Exception queue export prepared.",
})
}
>
<FileDownIcon aria-hidden="true" />
Export CSV
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.message("Queue refreshed", {
description: "Live data would refresh through your API.",
})
}
>
<RefreshCwIcon aria-hidden="true" />
Refresh
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.info("View settings", {
description: "Column and density controls are available.",
})
}
>
<SettingsIcon aria-hidden="true" />
View settings
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export function ExceptionGrid() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "value", desc: true },
])
const [searchQuery, setSearchQuery] = useState("")
const [selectedStatuses, setSelectedStatuses] = useState<FulfillmentStatus[]>(
[]
)
const [columnOrder, setColumnOrder] = useState<string[]>(
columns.map((column) => column.id as string)
)
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
risk: false,
})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const statusCounts = useMemo(
() =>
FULFILLMENT_ROWS.reduce(
(acc, row) => {
acc[row.status] = (acc[row.status] || 0) + 1
return acc
},
{} as Record<string, number>
),
[]
)
const filteredData = useMemo(() => {
return FULFILLMENT_ROWS.filter((row) => {
const matchesStatus =
!selectedStatuses.length || selectedStatuses.includes(row.status)
const matchesSearch =
!searchQuery ||
fulfillmentSearchBlob(row).includes(searchQuery.toLowerCase())
return matchesStatus && matchesSearch
})
}, [searchQuery, selectedStatuses])
const hasActiveFilters =
searchQuery.trim().length > 0 || selectedStatuses.length > 0
const resetToFirstPage = () => {
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
)
}
const handleSearchChange = (value: string) => {
setSearchQuery(value)
resetToFirstPage()
}
const handleStatusChange = (checked: boolean, status: FulfillmentStatus) => {
setSelectedStatuses((current) =>
checked ? [...current, status] : current.filter((item) => item !== status)
)
resetToFirstPage()
}
const handleClearFilters = () => {
setSelectedStatuses([])
setSearchQuery("")
resetToFirstPage()
}
const table = useReactTable({
columns,
data: filteredData,
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
getRowId: (row) => row.id,
state: { pagination, sorting, columnOrder, columnVisibility, rowSelection },
columnResizeMode: "onChange",
enableRowSelection: true,
autoResetPageIndex: false,
onColumnOrderChange: setColumnOrder,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
return (
<TooltipProvider delay={200}>
<ReuiDataGrid
table={table}
recordCount={filteredData.length}
emptyMessage={
filteredData.length === 0
? "No fulfillment exceptions match your filters."
: undefined
}
tableLayout={{
columnsPinnable: true,
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
headerSticky: true,
dense: true,
}}
tableClassNames={{
bodyRow: "[&>td]:h-16",
}}
>
<Frame variant="default" spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle className="text-balance">Exception Queue</FrameTitle>
<FrameDescription className="text-xs text-pretty">
{filteredData.length} of {FULFILLMENT_ROWS.length} fulfillment
records
</FrameDescription>
</div>
<Button
type="button"
onClick={() =>
toast.info("Create exception", {
description:
"Connect this button to your incident intake flow.",
})
}
>
<PlusIcon aria-hidden="true" />
Add exception
</Button>
</FrameHeader>
<FramePanel className="bg-card p-0! shadow-none!">
<div className="px-4 py-3">
<Toolbar
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
selectedStatuses={selectedStatuses}
onStatusChange={handleStatusChange}
onClearFilters={handleClearFilters}
hasActiveFilters={hasActiveFilters}
statusCounts={statusCounts}
/>
</div>
<Separator />
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</FramePanel>
<FrameFooter>
<DataGridPagination />
</FrameFooter>
</Frame>
</ReuiDataGrid>
</TooltipProvider>
)
}
@@ -1,246 +0,0 @@
import { useState } from "react"
import { format } from "date-fns"
import { type DateRange } from "react-day-picker"
import { toast } from "sonner"
import { Button } from "@evobgp/ui/components/button"
import { Calendar } from "@evobgp/ui/components/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { CalendarIcon, DownloadIcon } from "lucide-react"
type PeriodKey = "last30" | "prev30"
type ReportDateRange = {
from: Date
to: Date
}
type DateRangePreset = {
id: string
label: string
period: PeriodKey
range: ReportDateRange
}
const reportRange = (
fromMonth: number,
fromDay: number,
toMonth: number,
toDay: number,
year = 2026
): ReportDateRange => ({
from: new Date(year, fromMonth, fromDay),
to: new Date(year, toMonth, toDay),
})
const preset = (
id: string,
label: string,
period: PeriodKey,
range: ReportDateRange
): DateRangePreset => ({ id, label, period, range })
const LAST_30_RANGE = reportRange(4, 12, 5, 10)
const PREVIOUS_30_RANGE = reportRange(3, 12, 4, 11)
const REPORT_RANGE_PRESETS: DateRangePreset[] = [
preset("today", "Today", "last30", reportRange(5, 10, 5, 10)),
preset("yesterday", "Yesterday", "last30", reportRange(5, 9, 5, 9)),
preset("last7", "Last 7 days", "last30", reportRange(5, 4, 5, 10)),
preset("last30", "Last 30 days", "last30", LAST_30_RANGE),
preset("monthToDate", "Month to date", "last30", reportRange(5, 1, 5, 10)),
preset("lastMonth", "Last month", "last30", reportRange(4, 1, 4, 31)),
preset("yearToDate", "Year to date", "last30", reportRange(0, 1, 5, 10)),
preset("lastYear", "Last year", "prev30", reportRange(0, 1, 11, 31, 2025)),
]
const MAX_REPORT_DATE = LAST_30_RANGE.to
function isSameRange(first: ReportDateRange, second: DateRange) {
const secondFrom = second.from
const secondTo = second.to ?? second.from
return (
Boolean(secondFrom && secondTo) &&
first.from.getTime() === secondFrom?.getTime() &&
first.to.getTime() === secondTo?.getTime()
)
}
function normalizeRange(
range: DateRange | undefined,
fallback: ReportDateRange
): ReportDateRange {
if (!range?.from) return fallback
const from = range.from
const to = range.to ?? range.from
return from.getTime() <= to.getTime() ? { from, to } : { from: to, to: from }
}
function formatReportRange(range: ReportDateRange) {
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`
}
function getPeriodForRange(range: ReportDateRange) {
const matchingPreset = getMatchingPreset(range)
if (matchingPreset) return matchingPreset.period
return range.to.getTime() <= PREVIOUS_30_RANGE.to.getTime()
? "prev30"
: "last30"
}
function getMatchingPreset(range: DateRange | undefined) {
if (!range?.from || !range.to) return undefined
const normalizedRange = normalizeRange(range, LAST_30_RANGE)
return REPORT_RANGE_PRESETS.find((preset) =>
isSameRange(preset.range, normalizedRange)
)
}
function ReportDateRangePicker({
period,
onPeriodChange,
}: {
period: PeriodKey
onPeriodChange: (value: PeriodKey) => void
}) {
const initialRange = period === "prev30" ? PREVIOUS_30_RANGE : LAST_30_RANGE
const [open, setOpen] = useState(false)
const [committedRange, setCommittedRange] =
useState<ReportDateRange>(initialRange)
const [draftRange, setDraftRange] = useState<DateRange | undefined>(
initialRange
)
const selectedPresetId = getMatchingPreset(draftRange ?? committedRange)?.id
function handleOpenChange(nextOpen: boolean) {
if (nextOpen) {
setDraftRange(committedRange)
}
setOpen(nextOpen)
}
function handleApply() {
const nextRange = normalizeRange(draftRange, committedRange)
setCommittedRange(nextRange)
onPeriodChange(getPeriodForRange(nextRange))
setOpen(false)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={
<Button
type="button"
size="sm"
variant="outline"
className="group/pick-date w-[250px] max-w-full justify-between leading-none font-normal tabular-nums"
>
<span className="truncate">
{formatReportRange(committedRange)}
</span>
<CalendarIcon className="text-muted-foreground/80 group-hover/pick-date:text-foreground shrink-0 transition-colors" aria-hidden="true" />
</Button>
}
/>
<PopoverContent align="end" className="w-auto p-0">
<div className="flex flex-col">
<div className="flex flex-col sm:grid sm:grid-cols-[10rem_1fr]">
<div className="border-border flex flex-wrap gap-1 border-b p-2 sm:flex-col sm:border-r sm:border-b-0">
{REPORT_RANGE_PRESETS.map((preset) => {
const selected = selectedPresetId === preset.id
return (
<Button
key={preset.id}
type="button"
size="sm"
variant={selected ? "secondary" : "ghost"}
className={
selected
? "justify-start"
: "text-muted-foreground justify-start"
}
onClick={() => setDraftRange(preset.range)}
>
{preset.label}
</Button>
)
})}
</div>
<Calendar
mode="range"
selected={draftRange}
onSelect={setDraftRange}
numberOfMonths={2}
defaultMonth={draftRange?.from ?? committedRange.from}
disabled={{
after: MAX_REPORT_DATE,
}}
/>
</div>
<div className="border-border flex items-center justify-between gap-2 border-t px-3 py-1.5">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDraftRange(LAST_30_RANGE)}
>
Reset
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setDraftRange(committedRange)
setOpen(false)
}}
>
Cancel
</Button>
<Button type="button" size="sm" onClick={handleApply}>
Apply
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}
// Header action controls reused from the solution-agents-8 report toolbar.
export function NavbarActions() {
const [periodKey, setPeriodKey] = useState<PeriodKey>("last30")
function handleExport() {
toast.success("Export queued", {
description: "Fulfillment command report is being prepared.",
})
}
return (
<div className="flex shrink-0 items-center gap-2">
<ReportDateRangePicker period={periodKey} onPeriodChange={setPeriodKey} />
<Button size="sm" type="button" onClick={handleExport}>
<DownloadIcon aria-hidden="true" />
<span className="hidden sm:block">Export</span>
</Button>
</div>
)
}
@@ -1,34 +0,0 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@evobgp/ui/components/breadcrumb"
// Navbar breadcrumb
export function NavbarBreadcrumb() {
return (
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="hidden md:inline-flex">
<BreadcrumbLink render={<a href="#" />}>Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:flex" />
<BreadcrumbItem className="hidden md:inline-flex">
<BreadcrumbLink render={<a href="#" />}>Operations</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:flex" />
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">Fulfillment</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}
@@ -1,80 +0,0 @@
import { useState } from "react"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@evobgp/ui/components/avatar"
import { Button } from "@evobgp/ui/components/button"
import { Input } from "@evobgp/ui/components/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { TEAM_EXTRA_COUNT, TEAM_MEMBERS } from "./data"
import { UserPlusIcon } from "lucide-react"
// Header presence controls with team avatars and invite action.
export function NavbarPresence() {
const [email, setEmail] = useState("")
const [open, setOpen] = useState(false)
const handleInvite = () => {
if (!email.trim()) return
setEmail("")
setOpen(false)
}
return (
<div className="flex shrink-0 items-center gap-1.5">
<AvatarGroup>
{TEAM_MEMBERS.map((member, index) => (
<Avatar key={index} size="sm">
<AvatarImage src={member.src} alt={member.name} />
<AvatarFallback className="text-[9px]! font-medium">
{member.initials}
</AvatarFallback>
</Avatar>
))}
<AvatarGroupCount className="text-[10px]! font-medium">
+{TEAM_EXTRA_COUNT}
</AvatarGroupCount>
</AvatarGroup>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Invite team member"
/>
}
>
<UserPlusIcon aria-hidden="true" />
</PopoverTrigger>
<PopoverContent sideOffset={7} align="end" className="w-72">
<div className="flex flex-col gap-3">
<h4 className="text-foreground text-sm">Invite team member</h4>
<Input
type="email"
placeholder="email@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleInvite()}
/>
<Button onClick={handleInvite} disabled={!email.trim()}>
Send invite
</Button>
</div>
</PopoverContent>
</Popover>
</div>
)
}
@@ -1,17 +0,0 @@
import { NavbarActions } from "./navbar-actions"
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
// Navbar with breadcrumb and report range actions.
export function Navbar() {
return (
<header
className="flex min-h-9 w-full shrink-0 items-center justify-between gap-2 pb-1"
aria-label="Fulfillment command header"
>
<NavbarBreadcrumb />
<NavbarActions />
</header>
)
}
@@ -1,9 +0,0 @@
import { Dashboard } from "./components/dashboard"
export function Page() {
return (
<main className="bg-background min-h-svh w-full p-3 sm:p-4 lg:p-6">
<Dashboard />
</main>
)
}
@@ -1,141 +0,0 @@
import { Area, AreaChart, ResponsiveContainer, Tooltip } from "recharts"
import { Card, CardContent } from "@evobgp/ui/components/card"
import { activeUsersData, customersData, revenueData } from "./data"
// Business metric cards
const businessCards = [
{
title: "Revenue",
period: "reui.io, 28 days",
value: "$6.2K",
timestamp: "",
data: revenueData,
color: "var(--color-emerald-500)",
gradientId: "revenueGradient",
formatValue: (value: number) => `$${(value / 1000).toFixed(1)}K`,
},
{
title: "Signups",
period: "Last 28 days",
value: "4,238",
timestamp: "3h ago",
data: customersData,
color: "var(--color-blue-500)",
gradientId: "customersGradient",
formatValue: (value: number) => `${(value / 1000).toFixed(1)}K`,
},
{
title: "Active Licenses",
period: "ReUI Cloud, 28 days",
value: "4,238",
timestamp: "1h ago",
data: activeUsersData,
color: "var(--color-violet-500)",
gradientId: "usersGradient",
formatValue: (value: number) => `${(value / 1000).toFixed(1)}K`,
},
]
export function Chart() {
return (
<div className="@container w-full max-w-6xl">
<div className="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
{businessCards.map((card) => (
<Card key={card.title}>
<CardContent className="space-y-5">
{/* Header */}
<div className="text-sm font-semibold">{card.title}</div>
{/* Chart */}
<div className="flex items-end justify-between gap-2.5">
{/* Value */}
<div className="flex flex-col gap-px pb-2">
<div className="text-muted-foreground text-xs whitespace-nowrap">
{card.period}
</div>
<div className="text-foreground text-xl font-semibold tracking-tight">
{card.value}
</div>
</div>
<div className="relative h-16 w-full max-w-40">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={card.data}
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
>
<defs>
<linearGradient
id={card.gradientId}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={card.color}
stopOpacity={0.3}
/>
<stop
offset="100%"
stopColor={card.color}
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<Tooltip
cursor={{
stroke: card.color,
strokeWidth: 1,
strokeDasharray: "2 2",
}}
content={({ active, payload }) => {
if (active && payload && payload.length) {
const value = payload[0].value as number
return (
<Card className="bg-popover text-popover-foreground pointer-events-none p-0 shadow-md">
<CardContent className="flex min-w-24 flex-col gap-1 px-2 py-1.5">
<span className="text-muted-foreground text-[10px] leading-none font-medium">
{card.title}
</span>
<span className="text-popover-foreground text-xs leading-none font-semibold tabular-nums">
{card.formatValue(value)}
</span>
</CardContent>
</Card>
)
}
return null
}}
/>
<Area
type="monotone"
dataKey="value"
stroke={card.color}
fill={`url(#${card.gradientId})`}
strokeWidth={2}
dot={false}
activeDot={{
r: 4,
fill: card.color,
stroke: "white",
strokeWidth: 2,
}}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
@@ -1,338 +0,0 @@
import { type ComponentProps } from "react"
import { Badge } from "@/components/reui/badge"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import { type ColumnDef } from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@evobgp/ui/components/avatar"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import { type ModuleRecord, type ModuleStatus } from "./data"
import { CalendarDaysIcon, FileTextIcon, StarIcon, MoreHorizontalIcon, CopyIcon, ArchiveIcon } from "lucide-react"
export type ModuleRowAction = "open" | "favorite" | "duplicate" | "archive"
const moduleStatusVariant: Record<
ModuleStatus,
ComponentProps<typeof Badge>["variant"]
> = {
Planned: "info-outline",
Backlog: "outline",
"In Progress": "warning-outline",
}
const moduleStatusDotClass: Record<ModuleStatus, string> = {
Planned: "bg-sky-500 dark:bg-sky-400",
Backlog: "bg-muted-foreground/50",
"In Progress": "bg-amber-500 dark:bg-amber-400",
}
function DotSeparator() {
return (
<span
className="bg-muted-foreground/45 size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
)
}
function getProgressToneClass(value: number) {
if (value >= 75) return "text-emerald-500 dark:text-emerald-400"
if (value >= 40) return "text-amber-500 dark:text-amber-400"
if (value > 0) return "text-sky-500 dark:text-sky-400"
return "text-muted-foreground/35"
}
function getWindowDurationLabel(module: ModuleRecord) {
const start = new Date(module.dateStart).getTime()
const end = new Date(module.dateEnd).getTime()
const dayMs = 24 * 60 * 60 * 1000
const days = Math.max(1, Math.round((end - start) / dayMs))
return `${days}-day window`
}
function getCompactDateRange(module: ModuleRecord) {
return module.dateRange.replace(/, 2026/g, "")
}
function ModuleProgress({ module }: { module: ModuleRecord }) {
const value = module.progress
const radius = 18
const circumference = 2 * Math.PI * radius
const dashOffset = circumference - (value / 100) * circumference
const progressClassName = getProgressToneClass(value)
return (
<div className="flex min-w-0 items-center gap-2.5">
<div className="relative size-10 shrink-0">
<svg
viewBox="0 0 44 44"
className="absolute inset-0 size-10 -rotate-90"
aria-hidden="true"
>
<circle
cx="22"
cy="22"
r={radius}
fill="none"
className="stroke-muted-foreground/20"
strokeWidth="3.25"
/>
<circle
cx="22"
cy="22"
r={radius}
fill="none"
className={cn("stroke-current", progressClassName)}
strokeWidth="3.25"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
/>
</svg>
<span className="text-muted-foreground absolute inset-0 flex items-center justify-center text-[9px] leading-none font-medium tabular-nums">
{value}%
</span>
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground text-sm font-medium tabular-nums">
{value}% ready
</span>
<span className="text-muted-foreground truncate text-xs tabular-nums">
{module.tasksCompleted}/{module.tasksTotal} tasks
</span>
</div>
</div>
)
}
function ModuleNameCell({ module }: { module: ModuleRecord }) {
return (
<div className="flex min-w-0 flex-col gap-1">
<span className="text-foreground truncate text-sm leading-5 font-medium">
{module.name}
</span>
<div className="text-muted-foreground flex min-w-0 flex-wrap items-center gap-1.5 text-xs">
<span className="shrink-0">{module.kind}</span>
<DotSeparator />
<span className="inline-flex min-w-0 items-center gap-1.5">
<Avatar className="size-4 shrink-0">
{module.owner.avatar ? (
<AvatarImage src={module.owner.avatar} alt={module.owner.name} />
) : null}
<AvatarFallback className="text-[8px]">
{module.owner.initials}
</AvatarFallback>
</Avatar>
<span className="truncate">{module.owner.name}</span>
</span>
<DotSeparator />
<span className="truncate font-mono tracking-wide">
{module.domain}
</span>
</div>
</div>
)
}
function ModuleDateCell({ module }: { module: ModuleRecord }) {
return (
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground truncate text-sm font-medium tabular-nums">
{getCompactDateRange(module)}
</span>
<span className="text-muted-foreground inline-flex min-w-0 items-center gap-1.5 truncate text-xs">
<CalendarDaysIcon className="size-3.5 shrink-0" aria-hidden="true" />
{getWindowDurationLabel(module)}
</span>
</div>
)
}
function ModuleStatusCell({ module }: { module: ModuleRecord }) {
return (
<Badge variant={moduleStatusVariant[module.status]}>
<span
className={cn(
"size-1.5 shrink-0 rounded-full!",
moduleStatusDotClass[module.status]
)}
aria-hidden="true"
/>
{module.status}
</Badge>
)
}
function ModuleActions({
module,
onAction,
}: {
module: ModuleRecord
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
}) {
return (
<div className="flex items-center justify-end gap-1">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Open ${module.name}`}
onClick={(event) => {
event.stopPropagation()
onAction("open", module)
}}
>
<FileTextIcon aria-hidden="true" />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`${module.favorite ? "Unfavorite" : "Favorite"} ${
module.name
}`}
className={cn(module.favorite && "text-amber-500")}
onClick={(event) => {
event.stopPropagation()
onAction("favorite", module)
}}
>
<StarIcon aria-hidden="true" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`More actions for ${module.name}`}
onClick={(event) => event.stopPropagation()}
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onAction("duplicate", module)}>
<CopyIcon aria-hidden="true" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onAction("archive", module)}
>
<ArchiveIcon aria-hidden="true" />
Archive
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export function createModuleGridColumns({
onAction,
}: {
onAction: (action: ModuleRowAction, module: ModuleRecord) => void
}): ColumnDef<ModuleRecord>[] {
return [
{
accessorKey: "progress",
id: "progress",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ModuleProgress module={row.original} />,
size: 210,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Progress",
},
},
{
accessorKey: "name",
id: "name",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ModuleNameCell module={row.original} />,
minSize: 300,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
autoSize: true,
headerTitle: "Module",
},
},
{
accessorKey: "dateStart",
id: "dateStart",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ModuleDateCell module={row.original} />,
sortingFn: (rowA, rowB) =>
new Date(rowA.original.dateStart).getTime() -
new Date(rowB.original.dateStart).getTime(),
size: 180,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Window",
},
},
{
accessorKey: "status",
id: "status",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => <ModuleStatusCell module={row.original} />,
size: 126,
enableSorting: true,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Status",
},
},
{
id: "actions",
header: ({ column }) => (
<DataGridColumnHeader column={column} visibility={true} />
),
cell: ({ row }) => (
<ModuleActions module={row.original} onAction={onAction} />
),
size: 104,
enableSorting: false,
enableHiding: false,
enableResizing: false,
meta: {
headerTitle: "Actions",
},
},
]
}
@@ -1,33 +0,0 @@
"use client"
import { Chart } from "./chart"
import { ModulesDataGridView } from "./data-grid-view"
import { Navbar } from "./navbar"
/**
* ReUI operations dashboard: navbar -> metric charts -> module grid.
* The sections are copied from reviewed donor blocks.
* Customize: swap the records and chart series in data.tsx first.
*/
export function Dashboard() {
return (
<div className="bg-background text-foreground flex min-h-svh w-full flex-col">
<Navbar />
<main
className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 p-4 pt-6 sm:p-8"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
ReUI Operations Dashboard
</h1>
{/* Metric Charts */}
<Chart />
{/* Module Grid */}
<ModulesDataGridView />
</main>
</div>
)
}
@@ -1,380 +0,0 @@
import { useCallback, useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { DataGrid } from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import {
DataGridTable,
DataGridTableHeader,
} from "@/components/reui/data-grid/data-grid-table"
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type PaginationState,
type SortingState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evobgp/ui/components/input-group"
import { createModuleGridColumns, type ModuleRowAction } from "./columns"
import {
MODULE_RECORDS,
MODULE_STATUS_OPTIONS,
type ModuleRecord,
type ModuleStatus,
} from "./data"
import { CircleCheckIcon, FlagIcon, ChevronRightIcon, PackageIcon, SearchIcon, XIcon, ArrowUpDownIcon, ChevronDownIcon, FilterIcon } from "lucide-react"
type ModuleSort = "name" | "dateStart" | "progress" | "status"
const sortLabels: Record<ModuleSort, string> = {
name: "Name",
dateStart: "Window",
progress: "Progress",
status: "Status",
}
const EMPTY_MODULE_MESSAGE = "No ReUI modules match the selected filters."
function buildSorting(sortBy: ModuleSort): SortingState {
return [{ id: sortBy, desc: false }]
}
function getModuleSearchBlob(module: ModuleRecord) {
return [
module.name,
module.id,
module.kind,
module.domain,
module.owner.name,
module.owner.role,
module.health,
module.status,
module.dateRange,
]
.join(" ")
.toLowerCase()
}
export function ModulesDataGridView() {
const [modules, setModules] = useState<ModuleRecord[]>(MODULE_RECORDS)
const [searchQuery, setSearchQuery] = useState("")
const [selectedStatuses, setSelectedStatuses] = useState<ModuleStatus[]>([])
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [sortBy, setSortBy] = useState<ModuleSort>("name")
const [sorting, setSorting] = useState<SortingState>(() =>
buildSorting("name")
)
const filteredModules = useMemo(() => {
const normalizedSearchQuery = searchQuery.trim().toLowerCase()
return modules.filter((module) => {
const matchesSearch =
normalizedSearchQuery.length === 0 ||
getModuleSearchBlob(module).includes(normalizedSearchQuery)
const matchesStatus =
selectedStatuses.length === 0 ||
selectedStatuses.includes(module.status)
return matchesSearch && matchesStatus
})
}, [modules, searchQuery, selectedStatuses])
const activeFilterCount = selectedStatuses.length
const resetPagination = useCallback(() => {
setPagination((current) => ({
...current,
pageIndex: 0,
}))
}, [])
const handleSearchChange = useCallback(
(value: string) => {
setSearchQuery(value)
resetPagination()
},
[resetPagination]
)
const handleStatusToggle = useCallback(
(status: ModuleStatus, checked: boolean) => {
setSelectedStatuses((current) => {
if (checked) {
return current.includes(status) ? current : [...current, status]
}
return current.filter((item) => item !== status)
})
resetPagination()
},
[resetPagination]
)
const handleSortChange = useCallback(
(value: string) => {
const nextSort = value as ModuleSort
setSortBy(nextSort)
setSorting(buildSorting(nextSort))
resetPagination()
},
[resetPagination]
)
const handleModuleAction = useCallback(
(action: ModuleRowAction, module: ModuleRecord) => {
if (action === "favorite") {
setModules((current) =>
current.map((item) =>
item.id === module.id
? {
...item,
favorite: !item.favorite,
}
: item
)
)
toast.success(module.favorite ? "Removed favorite" : "Module starred", {
description: module.name,
icon: (
<CircleCheckIcon className="size-4" aria-hidden="true" />
),
})
return
}
if (action === "open") {
toast.info("Open ReUI module", {
description: `${module.name} (${module.kind})`,
})
return
}
toast.message(
action === "duplicate" ? "Duplicate module" : "Archive module",
{
description: `Connect this action to your ${module.name} flow.`,
}
)
},
[]
)
const handleAddModule = () => {
toast.success("Add ReUI module", {
description: "Open your module creation dialog.",
icon: (
<CircleCheckIcon className="size-4" aria-hidden="true" />
),
})
}
const columns = useMemo(
() => createModuleGridColumns({ onAction: handleModuleAction }),
[handleModuleAction]
)
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: filteredModules,
columns,
pageCount: Math.ceil(filteredModules.length / pagination.pageSize),
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
return (
<DataGrid
table={table}
recordCount={filteredModules.length}
emptyMessage={EMPTY_MODULE_MESSAGE}
tableLayout={{
dense: true,
rowBorder: true,
headerSticky: false,
columnsVisibility: false,
columnsResizable: false,
columnsMovable: false,
width: "fixed",
}}
tableClassNames={{
bodyRow: "group/module-row [&>td]:h-16",
}}
>
<div className="flex w-full max-w-6xl flex-col">
<div className="flex flex-col gap-3 border-b px-0 py-3 lg:min-h-14 lg:flex-row lg:items-center lg:gap-4 lg:py-0">
<div className="flex min-w-0 items-center gap-2">
<FlagIcon className="size-3.5 shrink-0 fill-amber-400 text-amber-400" aria-hidden="true" />
<span className="text-muted-foreground truncate text-sm">ReUI</span>
<ChevronRightIcon className="text-muted-foreground size-3.5 shrink-0" aria-hidden="true" />
<PackageIcon className="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
<h2 className="text-foreground truncate text-sm font-medium">
Modules
</h2>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2 lg:ml-auto lg:flex-nowrap">
<InputGroup className="w-full min-w-40 sm:w-48">
<InputGroupAddon align="inline-start">
<SearchIcon className="text-muted-foreground size-4" aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchQuery}
onChange={(event) => handleSearchChange(event.target.value)}
placeholder="Search..."
aria-label="Search modules"
/>
{searchQuery.length > 0 ? (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label="Clear search"
onClick={() => handleSearchChange("")}
>
<XIcon className="size-4" aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline">
<ArrowUpDownIcon data-icon="inline-start" aria-hidden="true" />
{sortLabels[sortBy]}
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuGroup>
{(["name", "dateStart", "progress", "status"] as const).map(
(value) => (
<DropdownMenuItem
key={value}
onClick={() => handleSortChange(value)}
>
{sortLabels[value]}
</DropdownMenuItem>
)
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline">
<FilterIcon data-icon="inline-start" aria-hidden="true" />
Filters
{activeFilterCount > 0 ? (
<Badge variant="outline" radius="full">
{activeFilterCount}
</Badge>
) : null}
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuGroup>
<DropdownMenuLabel>Status</DropdownMenuLabel>
{MODULE_STATUS_OPTIONS.map((status) => (
<DropdownMenuCheckboxItem
key={status}
checked={selectedStatuses.includes(status)}
closeOnClick={false}
onCheckedChange={(checked) =>
handleStatusToggle(status, checked === true)
}
>
{status}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{activeFilterCount > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
closeOnClick={false}
onClick={() => {
setSelectedStatuses([])
resetPagination()
}}
>
Reset filters
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<Button type="button" onClick={handleAddModule}>
Add Module
</Button>
</div>
</div>
{filteredModules.length > 0 ? (
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
) : (
<>
<DataGridScrollArea>
<DataGridTableHeader />
</DataGridScrollArea>
<div className="text-muted-foreground flex min-h-48 w-full items-center justify-center px-4 text-center text-sm">
{EMPTY_MODULE_MESSAGE}
</div>
</>
)}
<div className="border-t px-0 py-3">
{filteredModules.length > 0 ? (
<DataGridPagination
sizes={[10, 15, 20]}
info="{from} - {to} of {count} modules"
className="py-0"
/>
) : (
<p className="text-muted-foreground text-center text-sm">
0 modules
</p>
)}
</div>
</div>
</DataGrid>
)
}
@@ -1,490 +0,0 @@
export type ModuleStatus = "Planned" | "Backlog" | "In Progress"
export type ModuleKind = "System" | "Feature" | "Area"
export type ModuleHealth = "On Track" | "Watch" | "Blocked"
export interface ModuleOwner {
name: string
initials: string
role: string
avatar?: string
}
export interface ModuleRecord {
id: string
name: string
kind: ModuleKind
owner: ModuleOwner
domain: string
progress: number
tasksCompleted: number
tasksTotal: number
contributors: number
blockers: number
health: ModuleHealth
dateStart: string
dateEnd: string
dateRange: string
status: ModuleStatus
favorite: boolean
}
export const MODULE_STATUS_OPTIONS: ModuleStatus[] = [
"Planned",
"Backlog",
"In Progress",
]
const moduleOwners = {
maya: {
name: "Nora Vale",
initials: "NV",
role: "ReUI release lead",
avatar:
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
},
jonah: {
name: "Jonah Lee",
initials: "JL",
role: "ReUI product ops",
avatar:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
},
nina: {
name: "Nina Santos",
initials: "NS",
role: "Docs owner",
avatar:
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=96&h=96&dpr=2&q=80",
},
elijah: {
name: "Elijah Morgan",
initials: "EM",
role: "License lead",
avatar:
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&dpr=2&q=80",
},
priya: {
name: "Priya Shah",
initials: "PS",
role: "Lifecycle PM",
avatar:
"https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?w=96&h=96&dpr=2&q=80",
},
omar: {
name: "Omar Haddad",
initials: "OH",
role: "Trust owner",
avatar:
"https://images.unsplash.com/photo-1507591064344-4c6ce005b128?w=96&h=96&dpr=2&q=80",
},
sofia: {
name: "Sofia Romero",
initials: "SR",
role: "Content lead",
avatar:
"https://images.unsplash.com/photo-1517841905240-472988babdf9?w=96&h=96&dpr=2&q=80",
},
kenji: {
name: "Kenji Tan",
initials: "KT",
role: "Platform lead",
avatar:
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
},
lena: {
name: "Lena Wade",
initials: "LW",
role: "Developer tools",
},
} satisfies Record<string, ModuleOwner>
export const MODULE_RECORDS: ModuleRecord[] = [
{
id: "core-workflow",
name: "Registry Sync",
kind: "System",
owner: moduleOwners.maya,
domain: "registry.reui.io",
progress: 25,
tasksCompleted: 8,
tasksTotal: 32,
contributors: 6,
blockers: 0,
health: "On Track",
dateStart: "2026-04-17",
dateEnd: "2026-05-01",
dateRange: "Apr 17 - May 01, 2026",
status: "Planned",
favorite: false,
},
{
id: "onboarding-flow",
name: "Pro Onboarding",
kind: "Feature",
owner: moduleOwners.jonah,
domain: "pro.reui.io",
progress: 0,
tasksCompleted: 0,
tasksTotal: 18,
contributors: 4,
blockers: 0,
health: "Watch",
dateStart: "2026-04-19",
dateEnd: "2026-05-03",
dateRange: "Apr 19 - May 03, 2026",
status: "Backlog",
favorite: false,
},
{
id: "workspace-setup",
name: "Docs Portal",
kind: "Area",
owner: moduleOwners.nina,
domain: "docs.reui.io",
progress: 0,
tasksCompleted: 2,
tasksTotal: 14,
contributors: 3,
blockers: 1,
health: "Blocked",
dateStart: "2026-04-21",
dateEnd: "2026-05-05",
dateRange: "Apr 21 - May 05, 2026",
status: "In Progress",
favorite: false,
},
{
id: "permission-matrix",
name: "Access Matrix",
kind: "System",
owner: moduleOwners.maya,
domain: "admin.reui.io",
progress: 42,
tasksCompleted: 11,
tasksTotal: 26,
contributors: 5,
blockers: 0,
health: "Watch",
dateStart: "2026-04-22",
dateEnd: "2026-05-06",
dateRange: "Apr 22 - May 06, 2026",
status: "In Progress",
favorite: true,
},
{
id: "billing-rules",
name: "License Billing",
kind: "Feature",
owner: moduleOwners.elijah,
domain: "billing.reui.io",
progress: 64,
tasksCompleted: 21,
tasksTotal: 33,
contributors: 7,
blockers: 0,
health: "On Track",
dateStart: "2026-04-18",
dateEnd: "2026-05-02",
dateRange: "Apr 18 - May 02, 2026",
status: "In Progress",
favorite: false,
},
{
id: "notification-center",
name: "Release Notes",
kind: "Area",
owner: moduleOwners.priya,
domain: "changelog.reui.io",
progress: 18,
tasksCompleted: 5,
tasksTotal: 28,
contributors: 4,
blockers: 2,
health: "Blocked",
dateStart: "2026-04-23",
dateEnd: "2026-05-09",
dateRange: "Apr 23 - May 09, 2026",
status: "Backlog",
favorite: false,
},
{
id: "audit-trail",
name: "Trust Audit",
kind: "System",
owner: moduleOwners.omar,
domain: "trust.reui.io",
progress: 76,
tasksCompleted: 19,
tasksTotal: 25,
contributors: 5,
blockers: 0,
health: "On Track",
dateStart: "2026-04-15",
dateEnd: "2026-04-30",
dateRange: "Apr 15 - Apr 30, 2026",
status: "In Progress",
favorite: true,
},
{
id: "template-library",
name: "Block Library",
kind: "Feature",
owner: moduleOwners.sofia,
domain: "blocks.reui.io",
progress: 33,
tasksCompleted: 10,
tasksTotal: 30,
contributors: 6,
blockers: 0,
health: "Watch",
dateStart: "2026-04-24",
dateEnd: "2026-05-10",
dateRange: "Apr 24 - May 10, 2026",
status: "Planned",
favorite: false,
},
{
id: "integration-hub",
name: "Integration Hub",
kind: "Area",
owner: moduleOwners.kenji,
domain: "integrations.reui.io",
progress: 58,
tasksCompleted: 14,
tasksTotal: 24,
contributors: 8,
blockers: 1,
health: "Watch",
dateStart: "2026-04-20",
dateEnd: "2026-05-04",
dateRange: "Apr 20 - May 04, 2026",
status: "In Progress",
favorite: false,
},
{
id: "api-console",
name: "API Console",
kind: "Feature",
owner: moduleOwners.lena,
domain: "api.reui.io",
progress: 91,
tasksCompleted: 29,
tasksTotal: 32,
contributors: 4,
blockers: 0,
health: "On Track",
dateStart: "2026-04-12",
dateEnd: "2026-04-26",
dateRange: "Apr 12 - Apr 26, 2026",
status: "In Progress",
favorite: true,
},
{
id: "role-automation",
name: "Role Automation",
kind: "System",
owner: moduleOwners.maya,
domain: "admin.reui.io",
progress: 12,
tasksCompleted: 3,
tasksTotal: 25,
contributors: 3,
blockers: 1,
health: "Blocked",
dateStart: "2026-04-25",
dateEnd: "2026-05-12",
dateRange: "Apr 25 - May 12, 2026",
status: "Backlog",
favorite: false,
},
{
id: "workspace-invites",
name: "Team Invites",
kind: "Feature",
owner: moduleOwners.jonah,
domain: "teams.reui.io",
progress: 47,
tasksCompleted: 15,
tasksTotal: 32,
contributors: 5,
blockers: 0,
health: "Watch",
dateStart: "2026-04-19",
dateEnd: "2026-05-06",
dateRange: "Apr 19 - May 06, 2026",
status: "Planned",
favorite: false,
},
{
id: "release-checklist",
name: "Release Checklist",
kind: "Area",
owner: moduleOwners.nina,
domain: "release.reui.io",
progress: 84,
tasksCompleted: 26,
tasksTotal: 31,
contributors: 7,
blockers: 0,
health: "On Track",
dateStart: "2026-04-16",
dateEnd: "2026-05-01",
dateRange: "Apr 16 - May 01, 2026",
status: "In Progress",
favorite: false,
},
{
id: "reporting-digest",
name: "Usage Digest",
kind: "Feature",
owner: moduleOwners.priya,
domain: "reports.reui.io",
progress: 5,
tasksCompleted: 2,
tasksTotal: 38,
contributors: 3,
blockers: 0,
health: "Watch",
dateStart: "2026-04-28",
dateEnd: "2026-05-16",
dateRange: "Apr 28 - May 16, 2026",
status: "Backlog",
favorite: false,
},
{
id: "security-review",
name: "Security Review",
kind: "System",
owner: moduleOwners.omar,
domain: "trust.reui.io",
progress: 69,
tasksCompleted: 18,
tasksTotal: 26,
contributors: 6,
blockers: 2,
health: "Blocked",
dateStart: "2026-04-18",
dateEnd: "2026-05-07",
dateRange: "Apr 18 - May 07, 2026",
status: "In Progress",
favorite: false,
},
{
id: "help-center",
name: "Help Center",
kind: "Area",
owner: moduleOwners.sofia,
domain: "help.reui.io",
progress: 39,
tasksCompleted: 9,
tasksTotal: 23,
contributors: 4,
blockers: 0,
health: "On Track",
dateStart: "2026-04-23",
dateEnd: "2026-05-11",
dateRange: "Apr 23 - May 11, 2026",
status: "Planned",
favorite: false,
},
{
id: "data-retention",
name: "Data Retention",
kind: "System",
owner: moduleOwners.kenji,
domain: "privacy.reui.io",
progress: 22,
tasksCompleted: 7,
tasksTotal: 32,
contributors: 5,
blockers: 1,
health: "Watch",
dateStart: "2026-04-27",
dateEnd: "2026-05-14",
dateRange: "Apr 27 - May 14, 2026",
status: "Backlog",
favorite: false,
},
]
export const revenueData = [
{ value: 1000 },
{ value: 4500 },
{ value: 2000 },
{ value: 5200 },
{ value: 1500 },
{ value: 6100 },
{ value: 3000 },
{ value: 6800 },
{ value: 2000 },
{ value: 1000 },
{ value: 4000 },
{ value: 2000 },
{ value: 3000 },
{ value: 2000 },
{ value: 6238 },
]
export const customersData = [
{ value: 2000 },
{ value: 4500 },
{ value: 2000 },
{ value: 5200 },
{ value: 1500 },
{ value: 5100 },
{ value: 2500 },
{ value: 6800 },
{ value: 1800 },
{ value: 1000 },
{ value: 3000 },
{ value: 2000 },
{ value: 2700 },
{ value: 2000 },
{ value: 4238 },
]
export const activeUsersData = [
{ value: 2000 },
{ value: 3500 },
{ value: 2000 },
{ value: 5200 },
{ value: 1200 },
{ value: 4100 },
{ value: 3500 },
{ value: 5800 },
{ value: 2000 },
{ value: 800 },
{ value: 3000 },
{ value: 1000 },
{ value: 4000 },
{ value: 2000 },
{ value: 4238 },
]
export type TeamMember = {
src: string
name: string
initials: string
}
export const TEAM_MEMBERS: TeamMember[] = [
{
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
name: "Mira Stone",
initials: "MS",
},
{
src: "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
name: "Alex Johnson",
initials: "AJ",
},
{
src: "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
name: "Sarah Chen",
initials: "SC",
},
]
export const TEAM_EXTRA_COUNT = 8
@@ -1,67 +0,0 @@
import { toast } from "sonner"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import { PlusIcon, MoreHorizontalIcon, CopyIcon, Share2Icon, DownloadIcon, SettingsIcon } from "lucide-react"
// Navbar actions with a primary module action and overflow menu.
export function NavbarActions() {
const handleAddModule = () => {
toast.success("Add Module", {
description: "Open your module creation dialog.",
})
}
return (
<div className="flex shrink-0 items-center gap-2">
<Button type="button" onClick={handleAddModule}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
<span className="sr-only md:not-sr-only">Add Module</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="More options" />
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={7} align="end">
<DropdownMenuGroup>
<DropdownMenuItem>
<CopyIcon aria-hidden="true" />
Copy link
</DropdownMenuItem>
<DropdownMenuItem>
<Share2Icon aria-hidden="true" />
Share
</DropdownMenuItem>
<DropdownMenuItem>
<DownloadIcon aria-hidden="true" />
Export
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<SettingsIcon aria-hidden="true" />
Settings
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -1,35 +0,0 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from "@evobgp/ui/components/breadcrumb"
import { HouseIcon, LayoutDashboardIcon } from "lucide-react"
// Navbar breadcrumb
export function NavbarBreadcrumb() {
return (
<Breadcrumb>
{/* List */}
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center md:gap-1.5">
<HouseIcon className="text-muted-foreground size-3.5" aria-hidden="true" />
<span className="hidden md:block">Home</span>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>/</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="flex items-center gap-1.5">
<LayoutDashboardIcon className="text-muted-foreground size-3.5" aria-hidden="true" />
<span className="hidden md:block">ReUI</span>
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}
@@ -1,77 +0,0 @@
import { useState } from "react"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@evobgp/ui/components/avatar"
import { Button } from "@evobgp/ui/components/button"
import { Input } from "@evobgp/ui/components/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { Separator } from "@evobgp/ui/components/separator"
import { TEAM_EXTRA_COUNT, TEAM_MEMBERS } from "./data"
import { UserPlusIcon } from "lucide-react"
// Navbar presence with team avatars and invite
export function NavbarPresence() {
const [email, setEmail] = useState("")
const [open, setOpen] = useState(false)
const handleInvite = () => {
if (!email.trim()) return
setEmail("")
setOpen(false)
}
return (
<div className="flex shrink-0 items-center gap-2">
{/* List */}
<AvatarGroup>
{TEAM_MEMBERS.map((member, index) => (
<Avatar key={index} size="sm">
<AvatarImage src={member.src} alt={member.name} />
<AvatarFallback className="text-[8px]">
{member.initials}
</AvatarFallback>
</Avatar>
))}
<AvatarGroupCount>+{TEAM_EXTRA_COUNT}</AvatarGroupCount>
</AvatarGroup>
<Separator orientation="vertical" className="my-auto h-4" />
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={<Button variant="outline" aria-label="Invite team member" />}
>
<UserPlusIcon aria-hidden="true" />
<span className="hidden md:block">Invite</span>
</PopoverTrigger>
<PopoverContent sideOffset={7} align="end" className="w-72">
<div className="flex flex-col gap-3">
<h4 className="text-foreground text-sm">Invite team member</h4>
<Input
type="email"
placeholder="name@reui.io"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleInvite()}
/>
<Button onClick={handleInvite} disabled={!email.trim()}>
Send invite
</Button>
</div>
</PopoverContent>
</Popover>
</div>
)
}
@@ -1,20 +0,0 @@
import { NavbarActions } from "./navbar-actions"
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
import { NavbarPresence } from "./navbar-presence"
// Navbar with breadcrumb, team presence, and actions
export function Navbar() {
return (
<header className="border-border bg-background sticky top-0 z-20 flex h-12 w-full shrink-0 items-center justify-between gap-2 border-b px-4">
{/* Left - breadcrumb */}
<NavbarBreadcrumb />
{/* Right - team presence + actions */}
<div className="flex shrink-0 items-center gap-2">
<NavbarPresence />
<NavbarActions />
</div>
</header>
)
}
@@ -1,5 +0,0 @@
import { Dashboard } from "./components/dashboard"
export function Page() {
return <Dashboard />
}
@@ -1,90 +0,0 @@
import { useEffect, useRef } from "react"
import { cn } from "@evobgp/ui/lib/utils"
// Deterministic per-dot value so the field reads as noise, not a flat grid.
function grain(i: number, j: number) {
const n = Math.sin(i * 127.1 + j * 311.7) * 43758.5453
return n - Math.floor(n)
}
/**
* Static dot field adapted from card-5's reviewed background effect.
* The dots resolve from the current text color, so the surface stays
* neutral and theme-aware without hard-coded colors.
*/
export function CardDotField({ className }: { className?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const GAP = 3
const DOT = 1.5
const BASE = 0.03
const PEAK = 0.2
const draw = () => {
const rect = canvas.getBoundingClientRect()
if (!rect.width || !rect.height) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = Math.round(rect.width * dpr)
canvas.height = Math.round(rect.height * dpr)
ctx.fillStyle = getComputedStyle(canvas).color || "rgb(115,115,115)"
ctx.fillRect(0, 0, 1, 1)
const px = ctx.getImageData(0, 0, 1, 1).data
const color = `rgb(${px[0]}, ${px[1]}, ${px[2]})`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, rect.width, rect.height)
ctx.fillStyle = color
const cols = Math.ceil(rect.width / GAP) + 1
const rows = Math.ceil(rect.height / GAP) + 1
for (let i = 0; i < cols; i++) {
const x = i * GAP
for (let j = 0; j < rows; j++) {
const q = grain(i, j)
const amp = 0.7 + 0.6 * grain(j * 2 + 1, i * 2 + 1)
let a = (BASE + (PEAK - BASE) * q * q) * amp
if (a > 1) a = 1
ctx.globalAlpha = a
ctx.fillRect(x, j * GAP, DOT, DOT)
}
}
ctx.globalAlpha = 1
}
draw()
const resizeObserver = new ResizeObserver(() => draw())
resizeObserver.observe(canvas)
const themeObserver = new MutationObserver(() => draw())
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style"],
})
return () => {
resizeObserver.disconnect()
themeObserver.disconnect()
}
}, [])
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full",
className
)}
/>
)
}
@@ -1,51 +0,0 @@
import { Card, CardContent } from "@evobgp/ui/components/card"
type TooltipItem = {
value?: unknown
name?: unknown
color?: string
}
export function ChartTooltip({
active,
payload,
label,
}: {
active?: boolean
payload?: readonly TooltipItem[]
label?: string | number
}) {
if (!active || !payload?.length) return null
return (
<Card className="bg-popover text-popover-foreground pointer-events-none p-0 shadow-md">
<CardContent className="min-w-28 space-y-1.5 px-2 py-1.5">
<div className="text-muted-foreground text-[10px] leading-none">
{label}
</div>
<div className="space-y-1">
{payload.map((item, index) => (
<div
key={`${String(item.name)}-${index}`}
className="flex items-center justify-between gap-3 text-xs leading-none"
>
<span className="flex items-center gap-1.5">
<span
className="size-1.5 rounded-full"
style={{ backgroundColor: item.color }}
aria-hidden="true"
/>
<span className="text-muted-foreground">
{String(item.name)}
</span>
</span>
<span className="font-semibold tabular-nums">
{Number(item.value ?? 0).toLocaleString()}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)
}
@@ -1,200 +0,0 @@
import { type ComponentProps } from "react"
import { Badge } from "@/components/reui/badge"
export type MetricTone = "danger" | "success" | "warning" | "info"
export type MetricCard = {
id: string
title: string
label: string
value: string
delta: string
deltaVariant: ComponentProps<typeof Badge>["variant"]
detail: string
tone: MetricTone
sparkline: number[]
}
export const metricCards: MetricCard[] = [
{
id: "signal-risk",
title: "Signal Risk",
label: "Threat Index",
value: "Elevated",
delta: "+12.5%",
deltaVariant: "destructive-light",
detail: "5 cases",
tone: "danger",
sparkline: [18, 21, 15, 33, 29, 35, 28, 31, 19],
},
{
id: "mesh-uptime",
title: "Mesh Uptime",
label: "Service Health",
value: "99.98%",
delta: "+0.2%",
deltaVariant: "success-light",
detail: "58 zones",
tone: "success",
sparkline: [42, 40, 42, 38, 39, 41, 40, 43, 46],
},
{
id: "edge-traffic",
title: "Edge Traffic",
label: "Scrubbed Load",
value: "4.8 GB/s",
delta: "-3.1%",
deltaVariant: "warning-light",
detail: "clean flow",
tone: "warning",
sparkline: [21, 27, 28, 34, 32, 35, 33, 31, 34],
},
{
id: "sensor-reach",
title: "Sensor Reach",
label: "Global Nodes",
value: "18,420",
delta: "+6.8%",
deltaVariant: "success-light",
detail: "93 regions",
tone: "success",
sparkline: [25, 23, 29, 28, 26, 31, 27, 30, 29],
},
]
export const threatVectors = [
{ name: "Bot", blocked: 34, watched: 62 },
{ name: "Phish", blocked: 30, watched: 74 },
{ name: "DDoS", blocked: 52, watched: 33 },
{ name: "Inject", blocked: 22, watched: 48 },
{ name: "Auth", blocked: 43, watched: 68 },
{ name: "Probe", blocked: 18, watched: 58 },
{ name: "Exfil", blocked: 56, watched: 35 },
{ name: "Beacon", blocked: 38, watched: 64 },
{ name: "Day0", blocked: 14, watched: 28 },
]
export const networkFlow = [
{ month: "January", api: 1820, webhook: 1640 },
{ month: "February", api: 2340, webhook: 2160 },
{ month: "March", api: 1960, webhook: 1880 },
{ month: "April", api: 2780, webhook: 2540 },
{ month: "May", api: 2100, webhook: 1920 },
{ month: "June", api: 3120, webhook: 2880 },
{ month: "July", api: 2540, webhook: 2320 },
{ month: "August", api: 3480, webhook: 3160 },
{ month: "September", api: 2860, webhook: 2580 },
{ month: "October", api: 2420, webhook: 2140 },
{ month: "November", api: 3240, webhook: 2960 },
{ month: "December", api: 2680, webhook: 2440 },
]
export const networkFlowSummary = {
change: "+12.8%",
year: "2026",
}
const loadSamples = [
72, 68, 22, 18, 61, 71, 20, 26, 31, 70, 46, 88, 39, 25, 12, 33, 18, 28, 42,
36, 61, 68, 74, 70, 91, 32, 82, 66, 52, 76, 48, 35, 70, 62, 57, 49, 37, 58,
71, 7, 11, 69, 34, 28, 40, 61, 17, 55, 64, 19, 63, 67,
] as const
export type LoadState = "nominal" | "warm" | "critical"
export type LoadPoint = {
node: string
load: number
state: LoadState
}
export const loadDistribution: LoadPoint[] = loadSamples.map((load, index) => ({
node: `N${String(index).padStart(2, "0")}`,
load,
state: load >= 86 ? "critical" : load >= 76 ? "warm" : "nominal",
}))
const loadStateTotals = loadDistribution.reduce<Record<LoadState, number>>(
(totals, point) => ({
...totals,
[point.state]: totals[point.state] + 1,
}),
{
nominal: 0,
warm: 0,
critical: 0,
}
)
export const clusterLoadSummary = {
details: [
{
label: "Nodes",
value: String(loadDistribution.length),
variant: "secondary",
},
{
label: "Warm",
value: String(loadStateTotals.warm),
variant: "warning-light",
},
{
label: "Critical",
value: String(loadStateTotals.critical),
variant: "destructive-light",
},
],
} satisfies {
details: Array<{
label: string
value: string
variant: ComponentProps<typeof Badge>["variant"]
}>
}
export const activeThreats = [
{
id: "00",
label: "API Flood",
source: "Edge WAF",
state: "Mitigating",
value: 4521,
progress: 92,
tone: "danger",
},
{
id: "01",
label: "Mail Spoof",
source: "Mail Relay",
state: "Reviewing",
value: 3102,
progress: 64,
tone: "warning",
},
{
id: "02",
label: "Cloud Probe",
source: "Cloud API",
state: "Queued",
value: 1250,
progress: 26,
tone: "info",
},
{
id: "03",
label: "Mesh Beacon",
source: "Int Node",
state: "Watching",
value: 420,
progress: 9,
tone: "success",
},
] satisfies Array<{
id: string
label: string
source: string
state: string
value: number
progress: number
tone: MetricTone
}>
@@ -1,182 +0,0 @@
import { Badge } from "@/components/reui/badge"
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import { cn } from "@evobgp/ui/lib/utils"
import { Card, CardContent } from "@evobgp/ui/components/card"
import { ChartTooltip } from "./chart-tooltip"
import { activeThreats, clusterLoadSummary, loadDistribution } from "./data"
import { PanelCorners, PanelHeading } from "./panel-heading"
import { loadStateColor, toneStyles } from "./tone-styles"
const chartGridProps = {
vertical: false,
stroke: "var(--border)",
strokeDasharray: "3 3",
strokeOpacity: 0.75,
}
export function LoadPanel() {
return (
<Card className="relative h-full overflow-hidden p-0">
<PanelCorners />
<CardContent className="flex h-full flex-col p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-0.5">
<h2 className="text-sm leading-4 font-semibold">Cluster Load</h2>
<div className="text-muted-foreground flex flex-wrap items-center gap-3 text-xs leading-4">
<span className="flex items-center gap-1.5">
<span
className="size-2 rounded-full bg-zinc-300"
aria-hidden="true"
/>
Normal
</span>
<span className="flex items-center gap-1.5">
<span
className="size-2 rounded-full bg-amber-500"
aria-hidden="true"
/>
Warm
</span>
<span className="flex items-center gap-1.5">
<span
className="size-2 rounded-full bg-red-500"
aria-hidden="true"
/>
Critical
</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
{clusterLoadSummary.details.map((detail) => (
<Badge
key={detail.label}
variant={detail.variant}
radius="full"
className="h-6 gap-1.5 px-2 text-xs"
>
<span className="font-semibold tabular-nums">
{detail.value}
</span>
<span>{detail.label}</span>
</Badge>
))}
</div>
</div>
<div className="mt-auto h-60 pt-5">
<ResponsiveContainer width="100%" height="100%">
<BarChart
accessibilityLayer
data={loadDistribution}
margin={{ top: 10, right: 0, bottom: 0, left: 0 }}
barCategoryGap={2}
>
<CartesianGrid {...chartGridProps} />
<XAxis
dataKey="node"
axisLine={false}
interval={25}
tickLine={false}
tickMargin={10}
tick={{ fontSize: 10 }}
/>
<YAxis hide domain={[0, 100]} />
<Tooltip
cursor={{ fill: "var(--muted)" }}
content={({ active, payload, label }) => (
<ChartTooltip
active={active}
payload={payload}
label={label}
/>
)}
/>
<Bar
dataKey="load"
name="Load"
radius={[5, 5, 5, 5]}
isAnimationActive={false}
>
{loadDistribution.map((entry) => (
<Cell
key={entry.node}
fill={loadStateColor[entry.state]}
opacity={entry.state === "nominal" ? 0.62 : 1}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}
export function ActiveThreatsPanel() {
return (
<Card className="relative overflow-hidden p-0">
<PanelCorners />
<CardContent className="space-y-5 p-4">
<PanelHeading title="Active Lanes" description="Threat Queue" />
<div className="space-y-4">
{activeThreats.map((threat) => {
const tone = toneStyles[threat.tone]
return (
<div key={threat.id} className="space-y-2.5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn("size-2 shrink-0 rounded-full", tone.dot)}
aria-hidden="true"
/>
<span className="truncate text-sm font-medium">
[{threat.id}] {threat.label}
</span>
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-1.5 text-xs">
<span>{threat.source}</span>
<span
aria-hidden="true"
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
/>
<span>{threat.state}</span>
</div>
</div>
<div className="text-right text-sm font-semibold tabular-nums">
{threat.value.toLocaleString()}
</div>
</div>
<div className="flex items-center gap-2">
<div className="bg-muted h-1.5 min-w-0 flex-1 overflow-hidden rounded-full">
<div
className={cn("h-full rounded-full", tone.bar)}
style={{ width: `${threat.progress}%` }}
/>
</div>
<span className="text-muted-foreground w-8 text-right text-[10px] leading-none tabular-nums">
{threat.progress}%
</span>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}
@@ -1,83 +0,0 @@
import { Badge } from "@/components/reui/badge"
import { Card, CardContent } from "@evobgp/ui/components/card"
import { CardDotField } from "@/components/dashboard/card-dot-field"
import { type MetricCard } from "./data"
import { PanelCorners } from "./panel-heading"
import { toneStyles } from "./tone-styles"
function Sparkline({
values,
color,
}: {
values: readonly number[]
color: string
}) {
const min = Math.min(...values)
const max = Math.max(...values)
const spread = Math.max(1, max - min)
const points = values
.map((value, index) => {
const x = (index / (values.length - 1)) * 72
const y = 28 - ((value - min) / spread) * 22
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(" ")
return (
<svg
viewBox="0 0 72 32"
className="h-9 w-24 shrink-0 opacity-95"
role="img"
aria-label="Metric trend"
>
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="miter"
/>
</svg>
)
}
export function MetricTile({ metric }: { metric: MetricCard }) {
const tone = toneStyles[metric.tone]
return (
<Card className="relative overflow-hidden p-0">
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
<PanelCorners />
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground truncate text-sm leading-4 font-semibold">
{metric.title}
</span>
<span className="text-muted-foreground truncate text-xs leading-4">
{metric.label}
</span>
</div>
<div className="flex items-end justify-between gap-4">
<div className="min-w-0 space-y-2.5">
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight">
{metric.value}
</div>
<div className="flex items-center gap-2">
<Badge variant={metric.deltaVariant} radius="full">
{metric.delta}
</Badge>
<span className="text-muted-foreground text-xs">
{metric.detail}
</span>
</div>
</div>
<Sparkline values={metric.sparkline} color={tone.stroke} />
</div>
</CardContent>
</Card>
)
}
@@ -1,248 +0,0 @@
import { useState } from "react"
import { format } from "date-fns"
import { type DateRange } from "react-day-picker"
import { toast } from "sonner"
import { Button } from "@evobgp/ui/components/button"
import { Calendar } from "@evobgp/ui/components/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { CalendarIcon, ShieldCheckIcon, DownloadIcon } from "lucide-react"
type PeriodKey = "last30" | "prev30"
type ReportDateRange = {
from: Date
to: Date
}
type DateRangePreset = {
id: string
label: string
period: PeriodKey
range: ReportDateRange
}
const reportRange = (
fromMonth: number,
fromDay: number,
toMonth: number,
toDay: number,
year = 2026
): ReportDateRange => ({
from: new Date(year, fromMonth, fromDay),
to: new Date(year, toMonth, toDay),
})
const preset = (
id: string,
label: string,
period: PeriodKey,
range: ReportDateRange
): DateRangePreset => ({ id, label, period, range })
const LAST_30_RANGE = reportRange(4, 12, 5, 10)
const PREVIOUS_30_RANGE = reportRange(3, 12, 4, 11)
const REPORT_RANGE_PRESETS: DateRangePreset[] = [
preset("today", "Today", "last30", reportRange(5, 10, 5, 10)),
preset("yesterday", "Yesterday", "last30", reportRange(5, 9, 5, 9)),
preset("last7", "Last 7 days", "last30", reportRange(5, 4, 5, 10)),
preset("last30", "Last 30 days", "last30", LAST_30_RANGE),
preset("monthToDate", "Month to date", "last30", reportRange(5, 1, 5, 10)),
preset("lastMonth", "Last month", "last30", reportRange(4, 1, 4, 31)),
preset("yearToDate", "Year to date", "last30", reportRange(0, 1, 5, 10)),
preset("lastYear", "Last year", "prev30", reportRange(0, 1, 11, 31, 2025)),
]
const MAX_REPORT_DATE = LAST_30_RANGE.to
function isSameRange(first: ReportDateRange, second: DateRange) {
const secondFrom = second.from
const secondTo = second.to ?? second.from
return (
Boolean(secondFrom && secondTo) &&
first.from.getTime() === secondFrom?.getTime() &&
first.to.getTime() === secondTo?.getTime()
)
}
function normalizeRange(
range: DateRange | undefined,
fallback: ReportDateRange
): ReportDateRange {
if (!range?.from) return fallback
const from = range.from
const to = range.to ?? range.from
return from.getTime() <= to.getTime() ? { from, to } : { from: to, to: from }
}
function formatReportRange(range: ReportDateRange) {
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`
}
function getPeriodForRange(range: ReportDateRange) {
const matchingPreset = getMatchingPreset(range)
if (matchingPreset) return matchingPreset.period
return range.to.getTime() <= PREVIOUS_30_RANGE.to.getTime()
? "prev30"
: "last30"
}
function getMatchingPreset(range: DateRange | undefined) {
if (!range?.from || !range.to) return undefined
const normalizedRange = normalizeRange(range, LAST_30_RANGE)
return REPORT_RANGE_PRESETS.find((preset) =>
isSameRange(preset.range, normalizedRange)
)
}
function ReportDateRangePicker({
period,
onPeriodChange,
}: {
period: PeriodKey
onPeriodChange: (value: PeriodKey) => void
}) {
const initialRange = period === "prev30" ? PREVIOUS_30_RANGE : LAST_30_RANGE
const [open, setOpen] = useState(false)
const [committedRange, setCommittedRange] =
useState<ReportDateRange>(initialRange)
const [draftRange, setDraftRange] = useState<DateRange | undefined>(
initialRange
)
const selectedPresetId = getMatchingPreset(draftRange ?? committedRange)?.id
function handleOpenChange(nextOpen: boolean) {
if (nextOpen) {
setDraftRange(committedRange)
}
setOpen(nextOpen)
}
function handleApply() {
const nextRange = normalizeRange(draftRange, committedRange)
setCommittedRange(nextRange)
onPeriodChange(getPeriodForRange(nextRange))
setOpen(false)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={
<Button
type="button"
size="sm"
variant="outline"
className="group/pick-date w-[250px] max-w-full justify-between leading-none font-normal tabular-nums"
>
<span className="truncate">
{formatReportRange(committedRange)}
</span>
<CalendarIcon className="text-muted-foreground/80 group-hover/pick-date:text-foreground shrink-0 transition-colors" aria-hidden="true" />
</Button>
}
/>
<PopoverContent align="end" className="w-auto p-0">
<div className="flex flex-col">
<div className="flex flex-col sm:grid sm:grid-cols-[10rem_1fr]">
<div className="border-border flex flex-wrap gap-1 border-b p-2 sm:flex-col sm:border-r sm:border-b-0">
{REPORT_RANGE_PRESETS.map((preset) => {
const selected = selectedPresetId === preset.id
return (
<Button
key={preset.id}
type="button"
size="sm"
variant={selected ? "secondary" : "ghost"}
className={
selected
? "justify-start"
: "text-muted-foreground justify-start"
}
onClick={() => setDraftRange(preset.range)}
>
{preset.label}
</Button>
)
})}
</div>
<Calendar
mode="range"
selected={draftRange}
onSelect={setDraftRange}
numberOfMonths={2}
defaultMonth={draftRange?.from ?? committedRange.from}
disabled={{
after: MAX_REPORT_DATE,
}}
/>
</div>
<div className="border-border flex items-center justify-between gap-2 border-t px-3 py-1.5">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDraftRange(LAST_30_RANGE)}
>
Reset
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setDraftRange(committedRange)
setOpen(false)
}}
>
Cancel
</Button>
<Button type="button" size="sm" onClick={handleApply}>
Apply
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}
export function NavbarActions() {
const [periodKey, setPeriodKey] = useState<PeriodKey>("last30")
function handleExport() {
toast.success("Export queued", {
description: "Security telemetry report is being prepared.",
icon: (
<ShieldCheckIcon className="size-4" aria-hidden="true" />
),
})
}
return (
<div className="flex shrink-0 items-center gap-2">
<ReportDateRangePicker period={periodKey} onPeriodChange={setPeriodKey} />
<Button size="sm" type="button" onClick={handleExport}>
<DownloadIcon aria-hidden="true" />
<span className="hidden sm:block">Export</span>
</Button>
</div>
)
}
@@ -1,32 +0,0 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@evobgp/ui/components/breadcrumb"
export function NavbarBreadcrumb() {
return (
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="hidden md:inline-flex">
<BreadcrumbLink render={<a href="#" />}>Home</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:flex" />
<BreadcrumbItem className="hidden md:inline-flex">
<BreadcrumbLink render={<a href="#" />}>Security</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:flex" />
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">Telemetry</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}
@@ -1,15 +0,0 @@
import { NavbarActions } from "./navbar-actions"
import { NavbarBreadcrumb } from "./navbar-breadcrumb"
export function Navbar() {
return (
<header
className="flex min-h-9 w-full shrink-0 items-center justify-between gap-2 pb-1"
aria-label="Security telemetry header"
>
<NavbarBreadcrumb />
<NavbarActions />
</header>
)
}
@@ -1,305 +0,0 @@
import { Badge } from "@/components/reui/badge"
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
XAxis,
YAxis,
} from "recharts"
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@evobgp/ui/components/chart"
import { networkFlow, networkFlowSummary, threatVectors } from "./data"
import { PanelCorners, PanelHeading } from "./panel-heading"
const threatChartConfig = {
blocked: {
label: "Blocked",
color: "var(--color-blue-600)",
},
watched: {
label: "Watched",
color: "var(--color-sky-300)",
},
} satisfies ChartConfig
const flowChartConfig = {
api: {
label: "API Calls",
color: "var(--color-yellow-500)",
},
webhook: {
label: "Webhooks",
color: "var(--color-emerald-500)",
},
} satisfies ChartConfig
const chartGridProps = {
vertical: false,
stroke: "var(--border)",
strokeDasharray: "3 3",
strokeOpacity: 0.75,
}
function getChartColor(config: ChartConfig, name: string | number) {
return config[String(name)]?.color
}
function getChartLabel(config: ChartConfig, name: string | number) {
return config[String(name)]?.label ?? String(name)
}
function formatTooltipItem(
config: ChartConfig,
value: unknown,
name: string | number
) {
return (
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: getChartColor(config, name) }}
aria-hidden="true"
/>
<span className="text-muted-foreground">
{getChartLabel(config, name)}
</span>
</div>
<span className="text-foreground font-semibold tabular-nums">
{Number(value ?? 0).toLocaleString()}
</span>
</div>
)
}
function CrosshatchPattern({
config,
idPrefix,
}: {
config: ChartConfig
idPrefix: string
}) {
const entries = Object.entries(config).filter(([, value]) => value.color)
return (
<>
{entries.map(([key, { color }]) => (
<pattern
key={key}
id={`${idPrefix}-${key}`}
x="0"
y="0"
width="8"
height="8"
patternUnits="userSpaceOnUse"
>
<path d="M0,8 L8,0" stroke={color} strokeWidth="0.8" opacity="0.4" />
<path d="M0,0 L8,8" stroke={color} strokeWidth="0.8" opacity="0.2" />
</pattern>
))}
</>
)
}
export function ThreatVectorsPanel() {
return (
<Card className="relative overflow-hidden p-0">
<PanelCorners />
<CardContent className="flex flex-col gap-5 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<PanelHeading title="Threat Vectors" description="Blocked Signals" />
<div className="text-muted-foreground flex shrink-0 flex-wrap items-center gap-4 text-xs sm:justify-end">
<span className="flex items-center gap-1.5">
<span
className="size-2 rounded-full bg-blue-600"
aria-hidden="true"
/>
Blocked
</span>
<span className="flex items-center gap-1.5">
<span
className="size-2 rounded-full bg-sky-300"
aria-hidden="true"
/>
Watched
</span>
</div>
</div>
<ChartContainer
config={threatChartConfig}
className="h-56 w-full min-w-0"
>
<BarChart
accessibilityLayer
data={threatVectors}
margin={{ top: 8, right: 4, bottom: 0, left: 4 }}
barGap={3}
barCategoryGap={8}
>
<defs>
<pattern
id="dashboard5-threat-blocked"
patternUnits="userSpaceOnUse"
width="8"
height="8"
>
<rect
width="8"
height="8"
fill="var(--color-blocked)"
opacity="0.1"
/>
<path
d="M0,8 L8,0 M4,12 L12,4 M-4,4 L4,-4"
stroke="var(--color-blocked)"
strokeWidth="1.5"
opacity="0.55"
/>
<path
d="M2,10 L10,2 M6,14 L14,6 M-2,6 L6,-2"
stroke="var(--color-blocked)"
strokeWidth="1"
opacity="0.25"
/>
</pattern>
</defs>
<CartesianGrid {...chartGridProps} />
<XAxis
dataKey="name"
axisLine={false}
tickLine={false}
tickMargin={10}
tick={{ fontSize: 10 }}
/>
<YAxis hide domain={[0, 78]} />
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
indicator="dot"
className="min-w-36 gap-2"
labelFormatter={(value) => (
<div className="border-border/50 mb-0.5 border-b pb-2">
<span className="text-xs font-medium">{value}</span>
</div>
)}
formatter={(value, name) =>
formatTooltipItem(threatChartConfig, value, name)
}
/>
}
/>
<Bar
dataKey="blocked"
fill="url(#dashboard5-threat-blocked)"
stroke="var(--color-blocked)"
strokeWidth={1}
radius={[5, 5, 5, 5]}
isAnimationActive={false}
/>
<Bar
dataKey="watched"
fill="var(--color-watched)"
fillOpacity={0.86}
stroke="var(--color-watched)"
strokeWidth={1}
radius={[5, 5, 5, 5]}
isAnimationActive={false}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}
export function NetworkFlowPanel() {
return (
<Card className="relative overflow-hidden p-0">
<PanelCorners />
<CardContent className="flex flex-col gap-5 p-4">
<div className="flex items-start justify-between gap-3">
<PanelHeading title="Network Flow" description="API and webhooks" />
<Badge variant="success-light" radius="full">
{networkFlowSummary.change}
</Badge>
</div>
<ChartContainer
config={flowChartConfig}
className="h-56 w-full min-w-0"
>
<AreaChart
accessibilityLayer
data={networkFlow}
margin={{ top: 20, right: 0, bottom: 0, left: 0 }}
>
<CartesianGrid {...chartGridProps} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => String(value).slice(0, 3)}
/>
<YAxis hide />
<ChartTooltip
content={
<ChartTooltipContent
indicator="dot"
className="min-w-40 gap-2.5"
labelFormatter={(value) => (
<div className="border-border/50 mb-0.5 border-b pb-2">
<span className="text-xs font-medium">
{value} {networkFlowSummary.year}
</span>
</div>
)}
formatter={(value, name) =>
formatTooltipItem(flowChartConfig, value, name)
}
/>
}
/>
<defs>
<CrosshatchPattern
config={flowChartConfig}
idPrefix="dashboard5-flow-crosshatch"
/>
</defs>
<Area
dataKey="webhook"
type="natural"
fill="url(#dashboard5-flow-crosshatch-webhook)"
fillOpacity={0.5}
stroke="var(--color-webhook)"
stackId="a"
strokeWidth={1.25}
isAnimationActive={false}
/>
<Area
dataKey="api"
type="natural"
fill="url(#dashboard5-flow-crosshatch-api)"
fillOpacity={0.5}
stroke="var(--color-api)"
stackId="a"
strokeWidth={1.25}
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)
}
@@ -1,29 +0,0 @@
export function PanelCorners() {
return (
<>
<span
aria-hidden="true"
className="border-foreground/65 absolute top-0 left-0 size-2 border-t border-l"
/>
<span
aria-hidden="true"
className="border-foreground/65 absolute right-0 bottom-0 size-2 border-r border-b"
/>
</>
)
}
export function PanelHeading({
title,
description,
}: {
title: string
description: string
}) {
return (
<div className="space-y-0.5">
<h2 className="text-sm leading-4 font-semibold">{title}</h2>
<p className="text-muted-foreground text-xs leading-4">{description}</p>
</div>
)
}
@@ -1,47 +0,0 @@
import { metricCards } from "./data"
import { ActiveThreatsPanel, LoadPanel } from "./load-panels"
import { MetricTile } from "./metric-tile"
import { Navbar } from "./navbar"
import { NetworkFlowPanel, ThreatVectorsPanel } from "./network-panels"
/**
* Dense edge security dashboard inspired by a telemetry wall.
* The main entry only owns section order; records and panel details stay
* in focused local files so the block remains easy to adapt.
*/
export function SecurityDashboard() {
return (
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-4">
<h1 id="page-heading" className="sr-only">
Edge Security Telemetry
</h1>
<Navbar />
<section
aria-label="Security Summary"
className="grid grid-cols-1 gap-4 @3xl:grid-cols-2 @6xl:grid-cols-4"
>
{metricCards.map((metric) => (
<MetricTile key={metric.id} metric={metric} />
))}
</section>
<section
aria-label="Security Flow"
className="grid grid-cols-1 gap-4 @5xl:grid-cols-2"
>
<ThreatVectorsPanel />
<NetworkFlowPanel />
</section>
<section
aria-label="Security Load"
className="grid grid-cols-1 gap-4 @5xl:grid-cols-[minmax(0,2fr)_minmax(300px,1fr)]"
>
<LoadPanel />
<ActiveThreatsPanel />
</section>
</div>
)
}
@@ -1,37 +0,0 @@
import { type LoadState, type MetricTone } from "./data"
export const toneStyles: Record<
MetricTone,
{
dot: string
stroke: string
bar: string
}
> = {
danger: {
dot: "bg-red-500",
stroke: "var(--color-red-500)",
bar: "bg-red-500",
},
success: {
dot: "bg-emerald-500",
stroke: "var(--color-emerald-500)",
bar: "bg-emerald-500",
},
warning: {
dot: "bg-amber-500",
stroke: "var(--color-amber-500)",
bar: "bg-amber-500",
},
info: {
dot: "bg-blue-500",
stroke: "var(--color-blue-500)",
bar: "bg-blue-500",
},
}
export const loadStateColor: Record<LoadState, string> = {
nominal: "var(--color-zinc-300)",
warm: "var(--color-amber-500)",
critical: "var(--color-red-500)",
}
@@ -1,12 +0,0 @@
import { SecurityDashboard } from "./components/security-dashboard"
export function Page() {
return (
<main
className="bg-background min-h-svh w-full p-3 sm:p-4 lg:p-6"
aria-labelledby="page-heading"
>
<SecurityDashboard />
</main>
)
}
@@ -0,0 +1,116 @@
"use client"
import { useState, type ComponentType } from "react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@evobgp/ui/lib/utils"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
import { BillingTab } from "./billing-tab"
import { SETTINGS_TAB_ITEMS } from "./data"
import { NotificationsTab } from "./notifications-tab"
import { ProfileTab } from "./profile-tab"
import { SecurityTab } from "./security-tab"
const TAB_COMPONENTS: Record<string, ComponentType> = {
profile: ProfileTab,
security: SecurityTab,
notifications: NotificationsTab,
billing: BillingTab,
}
// ── Settings Navigation ──
function SettingsNavigation({
isMobile,
activeValue,
}: {
isMobile: boolean
activeValue: string
}) {
return (
<div className={cn("min-w-0", isMobile ? "w-full" : "w-40 shrink-0")}>
{isMobile ? (
<div className="-mx-1 overflow-x-auto px-1 pb-1">
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
{SETTINGS_TAB_ITEMS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
)}
>
{tab.icon}
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
</div>
) : (
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
{SETTINGS_TAB_ITEMS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
)}
>
{tab.icon}
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
)}
</div>
)
}
export function AccountSettings() {
const isMobile = useIsMobile()
const [activeTab, setActiveTab] = useState("profile")
return (
<div className="w-full max-w-4xl space-y-8">
{/* Header */}
<header className="px-1">
<h1 className="text-xl font-semibold tracking-tight">
Account Settings
</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
Update your profile, access, notifications, and billing preferences.
</p>
</header>
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
orientation={isMobile ? "horizontal" : "vertical"}
className={cn("w-full gap-4 lg:gap-8")}
>
<SettingsNavigation isMobile={isMobile} activeValue={activeTab} />
<div className="min-w-0 flex-1">
{SETTINGS_TAB_ITEMS.map((tab) => {
const TabComponent = TAB_COMPONENTS[tab.value]
return (
<TabsContent key={tab.value} value={tab.value} className="mt-0">
<TabComponent />
</TabsContent>
)
})}
</div>
</Tabs>
</div>
)
}
@@ -0,0 +1,180 @@
import { useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import { Input } from "@evobgp/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@evobgp/ui/components/input-group"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { BILLING_PLANS, COUNTRIES } from "./data"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
export function BillingTab() {
const [country, setCountry] = useState("us")
const handleCountryChange = createSelectValueHandler(setCountry)
return (
<div className="space-y-6">
{/* Card */}
<SettingsCard title="Plan and billing" description="Current subscription">
<SettingsFieldGroup
legend="Plan and billing"
description="Review your current subscription and plan options."
>
{BILLING_PLANS.map((plan, index) => (
<SettingRow
key={plan.id}
title={plan.name}
titleAddon={
plan.current ? (
<Badge variant="info-light" size="sm">
Current
</Badge>
) : null
}
description={
<>
<span className="text-foreground font-medium">
{plan.price}
</span>{" "}
/ {plan.period} · {plan.features.join(", ")}
</>
}
last={index === BILLING_PLANS.length - 1}
>
<Button variant={plan.current ? "outline" : "ghost"} size="sm">
{plan.current ? "Manage" : "Switch"}
</Button>
</SettingRow>
))}
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard
title="Billing details"
description="Invoices and payments"
footer={<Button>Update billing</Button>}
>
<SettingsFieldGroup
legend="Billing details"
description="Manage invoice contacts and payment details."
>
<SettingRow
title="Billing email"
description="Receives invoices and renewal notices."
labelFor="settings-7-billing-email"
>
<Input
id="settings-7-billing-email"
defaultValue="billing@acme.dev"
type="email"
/>
</SettingRow>
<SettingRow
title="Invoice profile"
description="Business details used on receipts and tax forms."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Invoice profile</FieldLegend>
<FieldDescription className="sr-only">
Company identity used for invoices and tax documents.
</FieldDescription>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="settings-7-company-name">
Company name
</FieldLabel>
<Input
id="settings-7-company-name"
defaultValue="Acme Labs"
/>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-billing-country">
Country
</FieldLabel>
<Select value={country} onValueChange={handleCountryChange}>
<SelectTrigger
id="settings-7-billing-country"
className="w-full"
>
<SelectValue>
{getOptionLabel(COUNTRIES, country)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{COUNTRIES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
<SettingRow
title="Tax ID"
description="Optional number used for VAT or company invoices."
labelFor="settings-7-tax-id"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>VAT</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-tax-id"
defaultValue="US-2048-ACME"
/>
</InputGroup>
</SettingRow>
<SettingRow
title="Card on file"
description="Used for monthly renewals."
last
>
<div className="flex flex-wrap items-center justify-end gap-2">
<span className="text-muted-foreground text-sm">
Visa ending in 4242
</span>
<Button variant="outline">Update</Button>
</div>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -0,0 +1,187 @@
"use client"
import { type ReactNode } from "react"
import { UserIcon, ShieldIcon, BellIcon, CreditCardIcon, MonitorIcon, SmartphoneIcon } from "lucide-react"
// ── Types ──
export type SelectOption = {
value: string
label: string
}
export type BillingPlan = {
id: string
name: string
price: string
period: string
current: boolean
features: string[]
}
export type Session = {
id: string
device: string
browser: string
location: string
lastActive: string
current: boolean
icon: ReactNode
}
export type SettingsTabItem = {
value: string
label: string
icon: ReactNode
}
// ── Data ──
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
{
value: "profile",
label: "My Profile",
icon: (
<UserIcon aria-hidden="true" />
),
},
{
value: "security",
label: "Security",
icon: (
<ShieldIcon aria-hidden="true" />
),
},
{
value: "notifications",
label: "Notifications",
icon: (
<BellIcon aria-hidden="true" />
),
},
{
value: "billing",
label: "Billing",
icon: (
<CreditCardIcon aria-hidden="true" />
),
},
]
export const TIMEZONES: SelectOption[] = [
{ value: "utc-8", label: "UTC-8 (Pacific Time)" },
{ value: "utc-5", label: "UTC-5 (Eastern Time)" },
{ value: "utc+0", label: "UTC+0 (London)" },
{ value: "utc+1", label: "UTC+1 (Berlin)" },
{ value: "utc+9", label: "UTC+9 (Tokyo)" },
]
export const ROLES: SelectOption[] = [
{ value: "engineering-lead", label: "Engineering Lead" },
{ value: "developer", label: "Developer" },
{ value: "designer", label: "Designer" },
{ value: "product-manager", label: "Product Manager" },
]
export const COUNTRIES: SelectOption[] = [
{ value: "us", label: "United States" },
{ value: "uk", label: "United Kingdom" },
{ value: "de", label: "Germany" },
{ value: "uz", label: "Uzbekistan" },
]
export const TIMEOUTS: SelectOption[] = [
{ value: "5", label: "5 minutes" },
{ value: "10", label: "10 minutes" },
{ value: "15", label: "15 minutes" },
{ value: "30", label: "30 minutes" },
]
export const QUIET_HOURS: SelectOption[] = [
{ value: "18:00", label: "6:00 PM" },
{ value: "20:00", label: "8:00 PM" },
{ value: "22:00", label: "10:00 PM" },
{ value: "23:00", label: "11:00 PM" },
]
export const QUIET_HOURS_END: SelectOption[] = [
{ value: "06:00", label: "6:00 AM" },
{ value: "08:00", label: "8:00 AM" },
{ value: "09:00", label: "9:00 AM" },
{ value: "10:00", label: "10:00 AM" },
]
export const DIGEST_CADENCE: SelectOption[] = [
{ value: "daily", label: "Daily summary" },
{ value: "weekly", label: "Weekly digest" },
{ value: "mentions", label: "Only mentions" },
]
export const RECOVERY_METHODS: SelectOption[] = [
{ value: "authenticator", label: "Authenticator first" },
{ value: "sms", label: "SMS fallback" },
{ value: "email", label: "Email fallback" },
]
export const BILLING_PLANS: BillingPlan[] = [
{
id: "free",
name: "Free",
price: "$0",
period: "forever",
current: false,
features: ["1 workspace", "Basic exports", "Community support"],
},
{
id: "pro",
name: "Pro",
price: "$29",
period: "per month",
current: true,
features: ["Unlimited workspaces", "Automations", "Priority support"],
},
{
id: "team",
name: "Team",
price: "$79",
period: "per month",
current: false,
features: ["Everything in Pro", "SSO", "Shared billing"],
},
]
export const SESSIONS: Session[] = [
{
id: "sess-1",
device: "macOS",
browser: "Chrome",
location: "San Francisco, CA",
lastActive: "Active now",
current: true,
icon: (
<MonitorIcon aria-hidden="true" />
),
},
{
id: "sess-2",
device: "iPhone",
browser: "Safari",
location: "San Francisco, CA",
lastActive: "2 hours ago",
current: false,
icon: (
<SmartphoneIcon aria-hidden="true" />
),
},
{
id: "sess-3",
device: "Windows",
browser: "Firefox",
location: "New York, NY",
lastActive: "3 days ago",
current: false,
icon: (
<MonitorIcon aria-hidden="true" />
),
},
]
@@ -0,0 +1,223 @@
import { useState } from "react"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Switch } from "@evobgp/ui/components/switch"
import { DIGEST_CADENCE, QUIET_HOURS, QUIET_HOURS_END, TIMEOUTS } from "./data"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
export function NotificationsTab() {
const [autoDismiss, setAutoDismiss] = useState("10")
const [quietStart, setQuietStart] = useState("20:00")
const [quietEnd, setQuietEnd] = useState("08:00")
const [digestCadence, setDigestCadence] = useState("weekly")
const handleAutoDismissChange = createSelectValueHandler(setAutoDismiss)
const handleQuietStartChange = createSelectValueHandler(setQuietStart)
const handleQuietEndChange = createSelectValueHandler(setQuietEnd)
const handleDigestCadenceChange = createSelectValueHandler(setDigestCadence)
return (
<div className="space-y-6">
{/* Card */}
<SettingsCard title="Activity alerts" description="Desktop and badge">
<SettingsFieldGroup
legend="Activity alerts"
description="Control desktop, badge, and sound alerts."
>
<SettingRow
title="Desktop notifications"
description="Show alerts for mentions and approvals."
labelFor="settings-7-desktop-notifications"
>
<Switch id="settings-7-desktop-notifications" defaultChecked />
</SettingRow>
<SettingRow
title="Quiet hours"
description="Hold non-urgent alerts outside your workday."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Quiet hours</FieldLegend>
<FieldDescription className="sr-only">
Define when non-urgent activity should stay muted.
</FieldDescription>
<FieldGroup className="gap-3 sm:grid sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="settings-7-quiet-start">
Start
</FieldLabel>
<Select
value={quietStart}
onValueChange={handleQuietStartChange}
>
<SelectTrigger
id="settings-7-quiet-start"
className="w-full"
>
<SelectValue>
{getOptionLabel(QUIET_HOURS, quietStart)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{QUIET_HOURS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-quiet-end">End</FieldLabel>
<Select value={quietEnd} onValueChange={handleQuietEndChange}>
<SelectTrigger id="settings-7-quiet-end" className="w-full">
<SelectValue>
{getOptionLabel(QUIET_HOURS_END, quietEnd)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{QUIET_HOURS_END.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
<SettingRow
title="Unread badge"
description="Display a badge when new activity arrives."
labelFor="settings-7-unread-badge"
>
<Switch id="settings-7-unread-badge" defaultChecked />
</SettingRow>
<SettingRow
title="Auto dismiss"
description="Choose how long alerts stay visible."
labelFor="settings-7-auto-dismiss"
last
>
<Select value={autoDismiss} onValueChange={handleAutoDismissChange}>
<SelectTrigger id="settings-7-auto-dismiss" className="w-full">
<SelectValue>
{getOptionLabel(TIMEOUTS, autoDismiss)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TIMEOUTS.map((timeout) => (
<SelectItem key={timeout.value} value={timeout.value}>
{timeout.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard
title="Email updates"
description="Inbox preferences"
footer={
<>
<Button variant="outline">Reset</Button>
<Button>Save preferences</Button>
</>
}
>
<SettingsFieldGroup
legend="Email updates"
description="Control which emails reach your inbox."
>
<SettingRow
title="Team communication"
description="Messages, approvals, and activity summaries."
labelFor="settings-7-team-communication"
>
<Switch id="settings-7-team-communication" defaultChecked />
</SettingRow>
<SettingRow
title="Product announcements"
description="Releases, improvements, and launches."
labelFor="settings-7-product-announcements"
>
<Switch id="settings-7-product-announcements" />
</SettingRow>
<SettingRow
title="Weekly digest"
description="A recap of workspaces, mentions, and tasks."
labelFor="settings-7-weekly-digest"
>
<Switch id="settings-7-weekly-digest" defaultChecked />
</SettingRow>
<SettingRow
title="Digest cadence"
description="Set how often summary emails arrive."
labelFor="settings-7-digest-cadence"
last
>
<Select
value={digestCadence}
onValueChange={handleDigestCadenceChange}
>
<SelectTrigger id="settings-7-digest-cadence" className="w-full">
<SelectValue>
{getOptionLabel(DIGEST_CADENCE, digestCadence)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{DIGEST_CADENCE.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -0,0 +1,103 @@
"use client"
import { useState } from "react"
import { useFileUpload } from "@/hooks/use-file-upload"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@evobgp/ui/components/avatar"
import { Button } from "@evobgp/ui/components/button"
import { UserCircle, XIcon, UploadIcon } from "lucide-react"
interface ProfileAvatarUploadProps {
defaultAvatar: string
alt: string
inputId?: string
}
export function ProfileAvatarUpload({
defaultAvatar,
alt,
inputId,
}: ProfileAvatarUploadProps) {
const [removedCurrentPhoto, setRemovedCurrentPhoto] = useState(false)
const [{ files }, { removeFile, openFileDialog, getInputProps }] =
useFileUpload({
accept: "image/*",
})
const currentFile = files[0] ?? null
const hasSavedPhoto = Boolean(defaultAvatar) && !removedCurrentPhoto
const previewUrl =
currentFile?.preview ?? (hasSavedPhoto ? defaultAvatar : null)
const hasPhoto = Boolean(previewUrl)
const handleCancelUpload = () => {
if (!currentFile) {
return
}
removeFile(currentFile.id)
}
const handleRemovePhoto = () => {
if (currentFile) {
removeFile(currentFile.id)
}
setRemovedCurrentPhoto(true)
}
return (
<div className="flex grow flex-wrap items-center justify-start gap-2">
{/* Actions */}
<div className="relative">
<Avatar className="size-10">
<AvatarImage src={previewUrl ?? undefined} alt={alt} />
<AvatarFallback className="text-muted-foreground bg-muted">
<UserCircle aria-hidden="true" className="size-4 opacity-60" />
</AvatarFallback>
</Avatar>
{currentFile ? (
<Button
variant="outline"
size="icon-xs"
onClick={handleCancelUpload}
className="absolute -top-1 -right-1 size-4 rounded-full"
aria-label={`Cancel ${currentFile.file.name}`}
>
<XIcon aria-hidden="true" />
</Button>
) : null}
</div>
<div className="relative inline-flex">
<Button
variant="outline"
size="sm"
onClick={openFileDialog}
aria-haspopup="dialog"
>
<UploadIcon aria-hidden="true" />
{hasPhoto ? "Change" : "Upload"}
</Button>
<input
{...getInputProps({ id: inputId })}
className="sr-only"
aria-label="Upload profile image"
tabIndex={-1}
/>
</div>
{hasPhoto ? (
<Button variant="outline" size="sm" onClick={handleRemovePhoto}>
<XIcon aria-hidden="true" />
Remove
</Button>
) : null}
</div>
)
}
@@ -0,0 +1,217 @@
import { useState } from "react"
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import { Input } from "@evobgp/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@evobgp/ui/components/input-group"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Textarea } from "@evobgp/ui/components/textarea"
import { ROLES, TIMEZONES } from "./data"
import { ProfileAvatarUpload } from "./profile-avatar-upload"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
import { UserIcon } from "lucide-react"
export function ProfileTab() {
const [role, setRole] = useState("engineering-lead")
const [timezone, setTimezone] = useState("utc-8")
const handleRoleChange = createSelectValueHandler(setRole)
const handleTimezoneChange = createSelectValueHandler(setTimezone)
return (
<div className="space-y-6">
<Alert variant="warning">
<UserIcon aria-hidden="true" />
<AlertTitle>Complete your profile.</AlertTitle>
<AlertDescription>
Add a photo and keep your role and timezone current.
</AlertDescription>
<AlertAction>
<Button type="button" variant="outline" size="xs">
Dismiss
</Button>
<Button type="button" size="xs">
Update
</Button>
</AlertAction>
</Alert>
{/* Card */}
<SettingsCard
title="My profile"
description="Public account details"
footer={
<>
<Button variant="outline">Cancel</Button>
<Button>Save changes</Button>
</>
}
>
<SettingsFieldGroup
legend="Profile fields"
description="Update your personal profile details."
>
<SettingRow
title="Photo"
description="Shown in comments and mentions."
>
<ProfileAvatarUpload
defaultAvatar="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80"
alt="Alex Morgan"
inputId="settings-7-profile-photo"
/>
</SettingRow>
<SettingRow
title="Full name"
description="Used across the workspace."
labelFor="settings-7-full-name"
>
<Input id="settings-7-full-name" defaultValue="Alex Morgan" />
</SettingRow>
<SettingRow
title="Email address"
description="Primary sign-in email."
labelFor="settings-7-email"
titleAddon={<Badge variant="success-light">Verified</Badge>}
>
<Input
id="settings-7-email"
defaultValue="alex@acme.dev"
type="email"
/>
</SettingRow>
<SettingRow
title="Username"
description="Visible in mentions and links."
labelFor="settings-7-username"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>@</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-username"
defaultValue="alexmorgan"
/>
</InputGroup>
</SettingRow>
<SettingRow
title="Profile details"
description="Public details shared across the workspace."
contentClassName="@md/field-group:w-[22rem]"
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">Profile details</FieldLegend>
<FieldDescription className="sr-only">
Public profile and workspace defaults.
</FieldDescription>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="settings-7-role">Role</FieldLabel>
<Select value={role} onValueChange={handleRoleChange}>
<SelectTrigger id="settings-7-role" className="w-full">
<SelectValue>{getOptionLabel(ROLES, role)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ROLES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-timezone">
Time zone
</FieldLabel>
<Select value={timezone} onValueChange={handleTimezoneChange}>
<SelectTrigger id="settings-7-timezone" className="w-full">
<SelectValue>
{getOptionLabel(TIMEZONES, timezone)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TIMEZONES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-website">Website</FieldLabel>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-website"
defaultValue="alexmorgan.dev"
/>
</InputGroup>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
<SettingRow
title="Bio"
description="Short profile summary."
labelFor="settings-7-bio"
last
>
<Textarea
id="settings-7-bio"
defaultValue="Building developer tools at Acme. Previously at Vercel and Stripe."
rows={4}
className="min-h-24 resize-none"
/>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -0,0 +1,257 @@
"use client"
import { Fragment, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Button } from "@evobgp/ui/components/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
import { Input } from "@evobgp/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@evobgp/ui/components/input-group"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@evobgp/ui/components/item"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Switch } from "@evobgp/ui/components/switch"
import { RECOVERY_METHODS, SESSIONS } from "./data"
import { SettingRow } from "./setting-row"
import { SettingsCard } from "./settings-card"
import { SettingsFieldGroup } from "./settings-field-group"
import { createSelectValueHandler, getOptionLabel } from "./utils"
import { LogOutIcon } from "lucide-react"
export function SecurityTab() {
const [recoveryMethod, setRecoveryMethod] = useState("authenticator")
const handleRecoveryMethodChange = createSelectValueHandler(setRecoveryMethod)
return (
<div className="space-y-6">
{/* Card */}
<SettingsCard
title="Change password"
description="Access protection"
footer={<Button>Update password</Button>}
>
<SettingsFieldGroup
legend="Password settings"
description="Update your password and keep your account protected."
>
<SettingRow
title="Current password"
description="Verify your identity before making changes."
labelFor="settings-7-current-password"
>
<Input
id="settings-7-current-password"
type="password"
placeholder="Enter current password"
/>
</SettingRow>
<SettingRow
title="New password"
description="Use at least 12 characters and a unique phrase."
contentClassName="@md/field-group:w-[22rem]"
last
>
<FieldSet className="w-full gap-3">
<FieldLegend className="sr-only">New password</FieldLegend>
<FieldDescription className="sr-only">
Create and confirm the next password for this account.
</FieldDescription>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="settings-7-new-password">
New password
</FieldLabel>
<Input
id="settings-7-new-password"
type="password"
placeholder="Create a new password"
/>
</Field>
<Field>
<FieldLabel htmlFor="settings-7-confirm-password">
Confirm password
</FieldLabel>
<Input
id="settings-7-confirm-password"
type="password"
placeholder="Confirm the new password"
/>
</Field>
</FieldGroup>
</FieldSet>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard
title="Two-step verification"
description="Extra sign-in checks"
>
<SettingsFieldGroup
legend="Two-step verification"
description="Add backup checks for future sign-ins."
>
<SettingRow
title="Authenticator app"
description="Use one-time codes from an app."
labelFor="settings-7-authenticator-app"
titleAddon={
<Badge variant="info-light" size="sm">
Recommended
</Badge>
}
>
<Switch id="settings-7-authenticator-app" defaultChecked />
</SettingRow>
<SettingRow
title="Recovery phone"
description="Used if you lose access to your authenticator app."
labelFor="settings-7-recovery-phone"
>
<InputGroup className="w-full">
<InputGroupAddon align="inline-start">
<InputGroupText>+1</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="settings-7-recovery-phone"
defaultValue="415 555 0148"
/>
</InputGroup>
</SettingRow>
<SettingRow
title="Delivery method"
description="Choose how backup verification requests are delivered."
labelFor="settings-7-recovery-method"
>
<Select
value={recoveryMethod}
onValueChange={handleRecoveryMethodChange}
>
<SelectTrigger id="settings-7-recovery-method" className="w-full">
<SelectValue>
{getOptionLabel(RECOVERY_METHODS, recoveryMethod)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{RECOVERY_METHODS.map((method) => (
<SelectItem key={method.value} value={method.value}>
{method.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</SettingRow>
<SettingRow
title="Trusted devices"
description="Skip repeat prompts on known devices."
labelFor="settings-7-trusted-devices"
last
>
<Switch id="settings-7-trusted-devices" defaultChecked />
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard title="Active sessions" description="Signed-in devices">
<ItemGroup className="gap-0">
{SESSIONS.map((session, index) => (
<Fragment key={session.id}>
{index > 0 ? <ItemSeparator className="my-0" /> : null}
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
<ItemMedia className="self-center!">
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
{session.icon}
</Item>
</ItemMedia>
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
<ItemTitle className="gap-2 leading-5">
{session.browser} on {session.device}
{session.current ? (
<Badge variant="success-light" size="xs">
Current
</Badge>
) : null}
</ItemTitle>
<ItemDescription className="leading-5">
{session.location} · {session.lastActive}
</ItemDescription>
</ItemContent>
<ItemActions className="w-28 shrink-0 justify-end self-center">
{session.current ? (
<Button variant="outline" size="sm">
This device
</Button>
) : (
<Button variant="ghost" size="sm">
<LogOutIcon data-icon="inline-start" aria-hidden="true" />
Revoke
</Button>
)}
</ItemActions>
</Item>
</Fragment>
))}
</ItemGroup>
</SettingsCard>
<SettingsCard title="Delete account" description="Irreversible changes">
<SettingsFieldGroup
legend="Delete account"
description="Review irreversible actions before deleting your account."
>
<SettingRow
title="Close account"
description="Permanently remove your account, sessions, and recovery settings."
last
contentClassName="@md/field-group:w-[22rem]"
>
<div className="flex w-full flex-col items-start gap-2 @md/field-group:items-end">
<p className="text-muted-foreground text-xs leading-5">
This action cannot be undone.
</p>
<Button variant="destructive">Delete account</Button>
</div>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
@@ -0,0 +1,2 @@
/** @deprecated Import from `@/components/settings/setting-row` — settings-7 is not a target. */
export { SettingRow } from '@/components/settings/setting-row'
@@ -0,0 +1,2 @@
/** @deprecated Import from `@/components/settings/settings-card` — settings-7 is not a target. */
export { SettingsCard } from '@/components/settings/settings-card'

Some files were not shown because too many files have changed in this diff Show More