Merge pull request #50 from AnsoCode/feature/animated-design-system

feat(design): animated design system foundation with animate-ui and motion
This commit is contained in:
Anso
2026-03-20 23:55:46 -04:00
committed by GitHub
41 changed files with 5801 additions and 532 deletions
+55 -43
View File
@@ -5,53 +5,65 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
- **Changed:** Notification delivery replaced polling with WebSocket push — `EditorLayout` no longer runs a `setInterval` to fetch `/api/notifications` every 5 seconds. A persistent `ws://host/ws/notifications` connection is opened on mount; the backend pushes each new alert in real-time as it is dispatched. Auto-reconnects with a 5-second back-off on close.
- **Added:** `NotificationService.setBroadcaster()` — injectable callback wired to a `notificationSubscribers` Set in `index.ts`; every authenticated WS client subscribed to `/ws/notifications` receives a `{ type: 'notification', payload: NotificationHistory }` message the moment `dispatchAlert` fires.
- **Fixed:** Animated design system overhaul — 9 UI bugs resolved: (1) compose.yaml ↔ .env tab switching no longer accumulates height (Monaco's internal `position:static` child created a circular CSS dependency with the grid; fixed by resetting Monaco to 0×0 then forcing a synchronous reflow before re-measuring); (2) sliding tab indicator on compose/env tabs; (3) sliding tab indicator on Notification tabs (Discord/Slack/Webhook); (4) switch thumb now animates position (added Radix data-attribute CSS translation); (5) "Always Local" badge tooltip no longer crashes the page (replaced animate-ui tooltip that called `getStrictContext` with pure Radix primitives); (6) Auto theme option added to Appearance settings (light/dark/auto with `window.matchMedia` listener); (7) Cancel/Add Node buttons in NodeManager dialogs no longer stuck together (added custom `DialogFooter` with proper gap classes); (8) AlertDialog now uses spring animation (`stiffness: 380, damping: 32`) and Quick Clean button text wraps correctly; (9) Resources/App Store/Logs menu buttons now toggle off on second click. Monaco container received `overflow-hidden` to prevent height escape.
- **Added:** `motion` package and `animate-ui` animated component library as the animation foundation for the design system.
- **Changed:** Design system overhauled — new brand cyan token (`--brand`, `oklch(0.72 0.14 200)` dark / `oklch(0.50 0.14 200)` light) applied to focus rings across both themes. CSS custom properties added for motion easing curves (`--ease-spring`, `--ease-out-expo`), animation durations, and stagger delay utilities. `prefers-reduced-motion` respected globally.
- **Changed:** Geist font loaded via Google Fonts CDN (was declared but never imported — no visual change, now actually resolved).
- **Changed:** Dark mode shadow system strengthened (opacity 0.18 → 0.35) for better depth on dark backgrounds. Scrollbar thumb uses correct `oklch()` values (was using `hsl(var(--muted))` which broke with OKLCH token format).
- **Changed:** `ui/dialog.tsx` — replaced CSS keyframe animations with animate-ui's spring-based 3D perspective flip (open: `rotateX(-20deg) scale(0.8)``scale(1)`, overlays fade + blur).
- **Changed:** `ui/tabs.tsx` — tab content transitions now use motion blur-fade (`filter: blur(4px)``blur(0px)`) instead of static CSS `animate-in`.
- **Changed:** `ui/switch.tsx` — thumb movement animated via spring physics (`stiffness: 300, damping: 25`); press gesture scales thumb by 1.15 for tactile feedback.
- **Changed:** `ui/tooltip.tsx` — tooltips pop in with spring scale animation (`stiffness: 350, damping: 28`) instead of CSS zoom-in.
- **Changed:** `EditorLayout` main workspace container keyed to `activeView` so every view switch triggers a `fade-up` entrance animation.
- **Fixed:** animate-ui `auto-height.tsx` imported `WithAsChild` without the `type` keyword, causing Vite to include a runtime import for a TypeScript-only type, crashing the browser module loader.
- **Fixed:** animate-ui `switch.tsx` double-spread all props (including Radix-only `onCheckedChange`) onto the underlying `motion.button` DOM element, triggering React `Unknown event handler property` warnings. Radix props are now explicitly destructured and passed only to `SwitchPrimitives.Root`.
- **Changed:** Notification delivery replaced polling with WebSocket push - `EditorLayout` no longer runs a `setInterval` to fetch `/api/notifications` every 5 seconds. A persistent `ws://host/ws/notifications` connection is opened on mount; the backend pushes each new alert in real-time as it is dispatched. Auto-reconnects with a 5-second back-off on close.
- **Added:** `NotificationService.setBroadcaster()` - injectable callback wired to a `notificationSubscribers` Set in `index.ts`; every authenticated WS client subscribed to `/ws/notifications` receives a `{ type: 'notification', payload: NotificationHistory }` message the moment `dispatchAlert` fires.
- **Changed:** `DatabaseService.addNotificationHistory` now returns the full inserted `NotificationHistory` record (including auto-generated `id` and `is_read: false`) instead of `void`, enabling the broadcaster to push the complete record to clients.
- **Added:** `/ws/notifications` WebSocket upgrade path in `index.ts` handled before the remote-node proxy path so it is always local. JWT auth verified manually (same pattern as all other WS paths per Directive 8).
- **Security:** `GET /api/settings` no longer leaks `auth_username`, `auth_password_hash`, or `auth_jwt_secret` to the frontend these keys are stripped from the response before sending.
- **Security:** `POST /api/settings` now enforces a strict allowlist of writable keys attempts to write auth credential keys (`auth_jwt_secret`, `auth_password_hash`, etc.) or arbitrary unknown keys are rejected with a 400 error.
- **Added:** `PATCH /api/settings` bulk-update endpoint accepts a partial settings object, validates all values via a Zod schema (type checking, range enforcement, URL format validation), and persists changes atomically in a single SQLite transaction. Replaces the N+1 per-key POST loop.
- **Added:** `system_state` SQLite table separates runtime operational state (e.g. janitor alert cooldown timestamp) from user-defined configuration in `global_settings`. `MonitorService` now writes `last_janitor_alert_timestamp` to `system_state` instead of `global_settings`, eliminating false positives in future audit logging.
- **Added:** `metrics_retention_hours` (default: 24h) and `log_retention_days` (default: 30d) configurable settings `MonitorService` now reads these dynamically each cycle instead of using hardcoded values. Notification history is pruned on the same cycle as container metrics.
- **Fixed:** Developer settings (`developer_mode`, `global_logs_refresh`, `metrics_retention_hours`, `log_retention_days`) are now correctly scoped to the local Sencho instance when a remote node is active, `fetchSettings` performs a secondary `localOnly` fetch for these keys and `saveDeveloperSettings` always writes to local via the new `apiFetch({ localOnly: true })` option, preventing UI preferences from being proxied into remote nodes' databases.
- **Added:** `localOnly` option on `apiFetch` omits the `x-node-id` header so requests always route to the local node regardless of the active node context.
- **Added:** `/ws/notifications` WebSocket upgrade path in `index.ts` - handled before the remote-node proxy path so it is always local. JWT auth verified manually (same pattern as all other WS paths per Directive 8).
- **Security:** `GET /api/settings` no longer leaks `auth_username`, `auth_password_hash`, or `auth_jwt_secret` to the frontend - these keys are stripped from the response before sending.
- **Security:** `POST /api/settings` now enforces a strict allowlist of writable keys - attempts to write auth credential keys (`auth_jwt_secret`, `auth_password_hash`, etc.) or arbitrary unknown keys are rejected with a 400 error.
- **Added:** `PATCH /api/settings` bulk-update endpoint - accepts a partial settings object, validates all values via a Zod schema (type checking, range enforcement, URL format validation), and persists changes atomically in a single SQLite transaction. Replaces the N+1 per-key POST loop.
- **Added:** `system_state` SQLite table - separates runtime operational state (e.g. janitor alert cooldown timestamp) from user-defined configuration in `global_settings`. `MonitorService` now writes `last_janitor_alert_timestamp` to `system_state` instead of `global_settings`, eliminating false positives in future audit logging.
- **Added:** `metrics_retention_hours` (default: 24h) and `log_retention_days` (default: 30d) configurable settings - `MonitorService` now reads these dynamically each cycle instead of using hardcoded values. Notification history is pruned on the same cycle as container metrics.
- **Fixed:** Developer settings (`developer_mode`, `global_logs_refresh`, `metrics_retention_hours`, `log_retention_days`) are now correctly scoped to the local Sencho instance - when a remote node is active, `fetchSettings` performs a secondary `localOnly` fetch for these keys and `saveDeveloperSettings` always writes to local via the new `apiFetch({ localOnly: true })` option, preventing UI preferences from being proxied into remote nodes' databases.
- **Added:** `localOnly` option on `apiFetch` - omits the `x-node-id` header so requests always route to the local node regardless of the active node context.
- **UI:** System Limits badge on remote nodes now reads "Configuring: [node name]"; Developer tab badge reads "Always Local" to make scope explicit to the user.
- **Refactor:** `SettingsModal` frontend overhauled per-operation loading states replace the single shared `isLoading` flag (saving system settings no longer disables notification test buttons). Settings are fetched before UI is interactive (skeleton loader blocks premature saves). Only known patchable keys are hydrated into component state (auth keys can never enter React state). Unsaved-changes dot indicator on sidebar nav items. All saves use the new `PATCH /api/settings` endpoint. Developer tab gains a "Data Retention" section for metrics and log retention controls.
- **Added:** App Store category filter LSIO templates are now grouped into categories (Automation, Downloaders, Media, Monitoring, Networking, Security, Development, Productivity, Utilities, Other) via a static lookup map in `TemplateService`. A horizontal pill bar below the search field lets users filter by category; clicking a category badge on a template card also activates the filter. Category badges highlight when their category is the active filter. App count updates reactively.
- **Added:** App Store registry settings new "App Store" section in Settings lets users supply a custom Portainer v2 JSON template URL to override the default LinuxServer.io registry. "Save & Refresh" persists the URL and immediately busts the 24-hour template cache via `POST /api/templates/refresh-cache`. Portainer v2 registries pass their native `categories` field through unchanged.
- **Added:** `source` field on the `Template` interface set to `'linuxserver'` for LSIO apps and `'custom'` for Portainer v2 registries, enabling future per-source filtering.
- **Fixed:** Container stats WebSocket flooding React with up to 20+ `setState` calls per second in `EditorLayout` each container's `onmessage` handler called `setContainerStats` independently, causing React to schedule a separate reconciliation pass per container per second. Replaced with a ref-buffer + 1.5 s flush interval pattern: incoming stats are written to `pendingStatsRef` (no re-render cost), snapshotted and cleared before a single batched `setContainerStats` call every 1.5 s. A separate `rawBytesRef` (never cleared) tracks raw rx/tx bytes for accurate rate calculation, avoiding the stale-closure bug that would have produced 0 B/s net I/O after every flush cycle. Buffer is also cleared on cleanup so stale entries from the previous stack don't flash in on stack switch.
- **Fixed:** Browser Out of Memory crash in `GlobalObservabilityView` (Logs view) the component rendered all log entries (up to 1,859+) as real DOM nodes with no virtualization, causing ~9,600 DOM nodes and ~25 MB of GC pressure every 5-second poll cycle. On RAM-constrained hosts (host system was at 97% usage) the browser renderer process OOMed within minutes. Fixed by capping DOM rendering to the last 300 entries (`MAX_DISPLAY_ROWS`), reducing the in-memory SSE log cap from 10,000 to 2,000 entries (`MAX_LOG_ENTRIES`), switching auto-scroll from `behavior: 'smooth'` (stacked layout animations) to `behavior: 'instant'`, and reducing the backend polling response limit from 2,000 to 500 lines. Also replaced `key={idx}` (array index) with a monotonic `_id` counter stamped at ingestion time so the slice window shifting no longer forces O(n) DOM mutations per new log line React now only reconciles the one entry that actually changed.
- **Fixed:** `HomeDashboard` create-stack error handling HTTP error responses were thrown as hardcoded strings, discarding the server's actual error message (e.g. "Stack already exists", "Invalid stack name"). Now reads the JSON error body before throwing, and uses the defensive `error?.message || error?.error || fallback` toast pattern.
- **Fixed:** LogViewer (container SSE log stream) returning 404 on remote nodes the `?nodeId=` query param was forwarded by `remoteNodeProxy` to the remote server, where `nodeContextMiddleware` rejected it with `Node X not found` because the gateway's node IDs don't exist on the remote instance. Fixed by stripping `nodeId` from `proxyReq.path` in `onProxyReq`, mirroring the existing `x-node-id` header removal.
- **Fixed:** Terminal logs and container stats WebSockets failing with "HTTP Authentication failed" on remote nodes the WebSocket upgrade proxy was forwarding the browser's `cookie` header to the remote Sencho instance. The remote's `authMiddleware` picks `cookieToken` before `bearerToken`, and the cookie (signed with the local JWT secret) fails verification on the remote, returning 401. Fixed by deleting the `cookie` header before `wsProxyServer.ws()`, mirroring the `proxyReq.removeHeader('cookie')` already present in the HTTP proxy. Also strips the gateway's `nodeId` query param from the forwarded URL so the remote defaults cleanly to its own local node.
- **Fixed:** `streamStats` Docker stats stream leaking after WebSocket client disconnect the Docker daemon stream was never destroyed when the WS closed, causing orphaned streams to poll the daemon indefinitely. Fixed by adding a `ws.on('close')` handler that calls `stats.destroy()`. Also guards all `ws.send()` calls with a `readyState === OPEN` check to prevent silent errors on a closed socket.
- **Fixed:** `streamStats` and `execContainer` called unawaited in the WS `connection` handler unhandled promise rejections (e.g., invalid container ID, Docker daemon unavailable) would terminate the Node.js process in production (Node ≥ 15). Fixed by chaining `.catch()` on both calls, logging the error, and closing the WebSocket cleanly.
- **Fixed:** Per-connection `WebSocket.Server` instances for stack logs and host console never closed after upgrade each WS connection allocated a persistent `ws.Server` object accumulating listeners. Fixed by calling `wss.close()` immediately after `handleUpgrade` completes.
- **Fixed:** `authMiddleware` and WS upgrade handler evaluated `cookieToken || bearerToken` a browser cookie present alongside a valid Bearer token would shadow it, risking 401 on node-to-node proxy calls. Fixed by inverting precedence to `bearerToken || cookieToken` in both places.
- **Fixed:** `remoteNodeProxy` error handler unsafely cast `proxyRes` to `Response` on WebSocket or TCP-level proxy errors `proxyRes` is a raw `Socket`, so calling `.status()` would throw. Fixed by type-narrowing before sending the 502 response.
- **Fixed:** Container stats (CPU/RAM/NET), bash exec terminal, and "Open App" button all broken for remote nodes stats and exec WebSockets connected to the bare root (`ws://host`) with no `?nodeId=` query param, so the upgrade handler couldn't detect the remote node and skipped the WS proxy. Moved all generic WebSockets to `/ws?nodeId=` path; upgrade handler now correctly proxies them to the remote Sencho instance.
- **Fixed:** Bash exec and stats WebSockets not reaching the backend in `npm run dev` Vite proxy config now includes `ws: true` on `/api` and a new `/ws` proxy entry so all WebSocket upgrades are forwarded to `localhost:3000`.
- **Fixed:** Backend WS message handler crashing when a proxied WebSocket arrives from a gateway and the forwarded `nodeId` doesn't exist in the remote instance's DB now falls back to the default local node instead of throwing.
- **Fixed:** "Open App" button opening `http://localhost:{port}` for remote node containers now resolves the hostname from the remote node's `api_url` so the correct remote host is used.
- **Fixed:** Remote node system stats, container stats, logs, and exec returning errors `remoteNodeProxy` middleware was already positioned before API route definitions, but `/api/system/stats` contained a dead remote branch that called `NodeRegistry.getDocker()` for remote nodes, which throws since remote nodes have no direct Docker socket access. Removed the broken branch; remote requests are correctly intercepted by the proxy middleware before reaching any route handler.
- **Added:** Background image update checker `ImageUpdateService` polls OCI-compliant registries (Docker Hub, GHCR, LSCR, etc.) every 6 hours using manifest digest comparison against local `RepoDigests`. Results cached in a new `stack_update_status` SQLite table. A pulsing blue dot badge appears in the stack list sidebar for stacks with available updates. Manual refresh available via `POST /api/image-updates/refresh` (rate-limited to once per 10 minutes).
- **Fixed:** `AppStoreView` and `GlobalObservabilityView` using raw `fetch()` instead of `apiFetch()` all calls now inject the `x-node-id` header so templates, deploys, stacks, and logs are correctly proxied to the active remote node.
- **Fixed:** `HostConsole` WebSocket URL missing `?nodeId=` query parameter the upgrade handler now receives the active node ID and routes the PTY session to the correct node.
- **Added:** Two-tier Option A scoped navigation UX a context pill in the top header bar always shows the active node name (pulsing blue for remote, green for local).
- **Added:** Remote-aware headers in `HostConsole` ("Host Console [Node Name]"), `ResourcesView` ("Resources Hub [Node Name]"), `GlobalObservabilityView` (floating node badge), and `AppStoreView` deploy sheet ("Deploying to: [Node Name]").
- **Added:** `SettingsModal` now scopes its sidebar to the active node type when a remote node is selected, global-only tabs (Account, Appearance, Notifications, Nodes) are hidden, and the header subtitle shows the remote node name.
- **Refactor:** `SettingsModal` frontend overhauled - per-operation loading states replace the single shared `isLoading` flag (saving system settings no longer disables notification test buttons). Settings are fetched before UI is interactive (skeleton loader blocks premature saves). Only known patchable keys are hydrated into component state (auth keys can never enter React state). Unsaved-changes dot indicator on sidebar nav items. All saves use the new `PATCH /api/settings` endpoint. Developer tab gains a "Data Retention" section for metrics and log retention controls.
- **Added:** App Store category filter - LSIO templates are now grouped into categories (Automation, Downloaders, Media, Monitoring, Networking, Security, Development, Productivity, Utilities, Other) via a static lookup map in `TemplateService`. A horizontal pill bar below the search field lets users filter by category; clicking a category badge on a template card also activates the filter. Category badges highlight when their category is the active filter. App count updates reactively.
- **Added:** App Store registry settings - new "App Store" section in Settings lets users supply a custom Portainer v2 JSON template URL to override the default LinuxServer.io registry. "Save & Refresh" persists the URL and immediately busts the 24-hour template cache via `POST /api/templates/refresh-cache`. Portainer v2 registries pass their native `categories` field through unchanged.
- **Added:** `source` field on the `Template` interface - set to `'linuxserver'` for LSIO apps and `'custom'` for Portainer v2 registries, enabling future per-source filtering.
- **Fixed:** Container stats WebSocket flooding React with up to 20+ `setState` calls per second in `EditorLayout` - each container's `onmessage` handler called `setContainerStats` independently, causing React to schedule a separate reconciliation pass per container per second. Replaced with a ref-buffer + 1.5 s flush interval pattern: incoming stats are written to `pendingStatsRef` (no re-render cost), snapshotted and cleared before a single batched `setContainerStats` call every 1.5 s. A separate `rawBytesRef` (never cleared) tracks raw rx/tx bytes for accurate rate calculation, avoiding the stale-closure bug that would have produced 0 B/s net I/O after every flush cycle. Buffer is also cleared on cleanup so stale entries from the previous stack don't flash in on stack switch.
- **Fixed:** Browser Out of Memory crash in `GlobalObservabilityView` (Logs view) - the component rendered all log entries (up to 1,859+) as real DOM nodes with no virtualization, causing ~9,600 DOM nodes and ~25 MB of GC pressure every 5-second poll cycle. On RAM-constrained hosts (host system was at 97% usage) the browser renderer process OOMed within minutes. Fixed by capping DOM rendering to the last 300 entries (`MAX_DISPLAY_ROWS`), reducing the in-memory SSE log cap from 10,000 to 2,000 entries (`MAX_LOG_ENTRIES`), switching auto-scroll from `behavior: 'smooth'` (stacked layout animations) to `behavior: 'instant'`, and reducing the backend polling response limit from 2,000 to 500 lines. Also replaced `key={idx}` (array index) with a monotonic `_id` counter stamped at ingestion time so the slice window shifting no longer forces O(n) DOM mutations per new log line - React now only reconciles the one entry that actually changed.
- **Fixed:** `HomeDashboard` create-stack error handling - HTTP error responses were thrown as hardcoded strings, discarding the server's actual error message (e.g. "Stack already exists", "Invalid stack name"). Now reads the JSON error body before throwing, and uses the defensive `error?.message || error?.error || fallback` toast pattern.
- **Fixed:** LogViewer (container SSE log stream) returning 404 on remote nodes - the `?nodeId=` query param was forwarded by `remoteNodeProxy` to the remote server, where `nodeContextMiddleware` rejected it with `Node X not found` because the gateway's node IDs don't exist on the remote instance. Fixed by stripping `nodeId` from `proxyReq.path` in `onProxyReq`, mirroring the existing `x-node-id` header removal.
- **Fixed:** Terminal logs and container stats WebSockets failing with "HTTP Authentication failed" on remote nodes - the WebSocket upgrade proxy was forwarding the browser's `cookie` header to the remote Sencho instance. The remote's `authMiddleware` picks `cookieToken` before `bearerToken`, and the cookie (signed with the local JWT secret) fails verification on the remote, returning 401. Fixed by deleting the `cookie` header before `wsProxyServer.ws()`, mirroring the `proxyReq.removeHeader('cookie')` already present in the HTTP proxy. Also strips the gateway's `nodeId` query param from the forwarded URL so the remote defaults cleanly to its own local node.
- **Fixed:** `streamStats` Docker stats stream leaking after WebSocket client disconnect - the Docker daemon stream was never destroyed when the WS closed, causing orphaned streams to poll the daemon indefinitely. Fixed by adding a `ws.on('close')` handler that calls `stats.destroy()`. Also guards all `ws.send()` calls with a `readyState === OPEN` check to prevent silent errors on a closed socket.
- **Fixed:** `streamStats` and `execContainer` called unawaited in the WS `connection` handler - unhandled promise rejections (e.g., invalid container ID, Docker daemon unavailable) would terminate the Node.js process in production (Node ≥ 15). Fixed by chaining `.catch()` on both calls, logging the error, and closing the WebSocket cleanly.
- **Fixed:** Per-connection `WebSocket.Server` instances for stack logs and host console never closed after upgrade - each WS connection allocated a persistent `ws.Server` object accumulating listeners. Fixed by calling `wss.close()` immediately after `handleUpgrade` completes.
- **Fixed:** `authMiddleware` and WS upgrade handler evaluated `cookieToken || bearerToken` - a browser cookie present alongside a valid Bearer token would shadow it, risking 401 on node-to-node proxy calls. Fixed by inverting precedence to `bearerToken || cookieToken` in both places.
- **Fixed:** `remoteNodeProxy` error handler unsafely cast `proxyRes` to `Response` - on WebSocket or TCP-level proxy errors `proxyRes` is a raw `Socket`, so calling `.status()` would throw. Fixed by type-narrowing before sending the 502 response.
- **Fixed:** Container stats (CPU/RAM/NET), bash exec terminal, and "Open App" button all broken for remote nodes - stats and exec WebSockets connected to the bare root (`ws://host`) with no `?nodeId=` query param, so the upgrade handler couldn't detect the remote node and skipped the WS proxy. Moved all generic WebSockets to `/ws?nodeId=` path; upgrade handler now correctly proxies them to the remote Sencho instance.
- **Fixed:** Bash exec and stats WebSockets not reaching the backend in `npm run dev` - Vite proxy config now includes `ws: true` on `/api` and a new `/ws` proxy entry so all WebSocket upgrades are forwarded to `localhost:3000`.
- **Fixed:** Backend WS message handler crashing when a proxied WebSocket arrives from a gateway and the forwarded `nodeId` doesn't exist in the remote instance's DB - now falls back to the default local node instead of throwing.
- **Fixed:** "Open App" button opening `http://localhost:{port}` for remote node containers - now resolves the hostname from the remote node's `api_url` so the correct remote host is used.
- **Fixed:** Remote node system stats, container stats, logs, and exec returning errors - `remoteNodeProxy` middleware was already positioned before API route definitions, but `/api/system/stats` contained a dead remote branch that called `NodeRegistry.getDocker()` for remote nodes, which throws since remote nodes have no direct Docker socket access. Removed the broken branch; remote requests are correctly intercepted by the proxy middleware before reaching any route handler.
- **Added:** Background image update checker - `ImageUpdateService` polls OCI-compliant registries (Docker Hub, GHCR, LSCR, etc.) every 6 hours using manifest digest comparison against local `RepoDigests`. Results cached in a new `stack_update_status` SQLite table. A pulsing blue dot badge appears in the stack list sidebar for stacks with available updates. Manual refresh available via `POST /api/image-updates/refresh` (rate-limited to once per 10 minutes).
- **Fixed:** `AppStoreView` and `GlobalObservabilityView` using raw `fetch()` instead of `apiFetch()` - all calls now inject the `x-node-id` header so templates, deploys, stacks, and logs are correctly proxied to the active remote node.
- **Fixed:** `HostConsole` WebSocket URL missing `?nodeId=` query parameter - the upgrade handler now receives the active node ID and routes the PTY session to the correct node.
- **Added:** Two-tier Option A scoped navigation UX - a context pill in the top header bar always shows the active node name (pulsing blue for remote, green for local).
- **Added:** Remote-aware headers in `HostConsole` ("Host Console - [Node Name]"), `ResourcesView` ("Resources Hub - [Node Name]"), `GlobalObservabilityView` (floating node badge), and `AppStoreView` deploy sheet ("Deploying to: [Node Name]").
- **Added:** `SettingsModal` now scopes its sidebar to the active node type - when a remote node is selected, global-only tabs (Account, Appearance, Notifications, Nodes) are hidden, and the header subtitle shows the remote node name.
- **Fixed:** A massive memory leak (browser Out of Memory crash) by throttling historical metrics polling down to 60s and downsampling SQLite metrics payload sizes by 12x.
- **Fixed:** A bug where the active node UI dropdown would desync from the actual API requests on initial page load by properly hydrating state from localStorage.
- **Fixed:** Remote node proxy forwarding the browser's `sencho_token` cookie to the remote Sencho instance the remote's `authMiddleware` evaluates `cookieToken || bearerToken` and the cookie (signed with the local JWT secret) was validated before the valid Bearer token, causing 401 on all proxied API calls. Fixed by stripping the `cookie` header in `proxyReq` so only the Bearer token is used for remote authentication.
- **Fixed:** `nodeContextMiddleware` blocking `/api/nodes` when `x-node-id` references a deleted/non-existent node the nodes list endpoint must always succeed so the frontend can re-sync a stale node ID in localStorage; exempted alongside `/api/auth/`.
- **Fixed:** Remote node proxy stripping the `/api` path prefix `remoteNodeProxy` is mounted at `app.use('/api/', ...)` so Express strips that prefix from `req.url` before `http-proxy-middleware` sees it; added `pathRewrite: (path) => '/api' + path` to restore the full path when forwarding to the remote Sencho instance (e.g. `/stats``/api/stats`). This was the root cause of all remote API calls returning the remote's SPA HTML instead of JSON.
- **Fixed:** Dashboard cards (Active Containers, Host CPU, Host RAM, Docker Network) showing stale local-node data after switching to a remote node `HomeDashboard` polling effects now depend on `activeNode?.id` and clear state immediately on node change.
- **Fixed:** `refreshStacks` crashing with `SyntaxError` or `TypeError` when the remote proxy returns a non-JSON response (e.g., connection refused to unreachable remote node) now checks `res.ok` before calling `res.json()` and iterates a typed `fileList` instead of the raw parsed value.
- **Fixed:** Restored Local/Remote type selector and fixed state resets in the Add Node modal form now resets to defaults every time the dialog opens, and the title reflects the chosen type dynamically.
- **Fixed:** Remote node connection details failing to display Containers, Images, and CPU metrics `testRemoteConnection` now fires parallel requests to `/api/stats`, `/api/system/stats`, and `/api/system/images` after auth succeeds, mapping real values into the info panel.
- **Fixed:** Suppressed `[DEP0060] DeprecationWarning: util._extend` from `http-proxy@1.18.1` override is applied to `process.emitWarning` before the proxy instances are created, cleanly intercepting the warning at its call site without suppressing other warnings.
- **Fixed:** Remote node proxy forwarding the browser's `sencho_token` cookie to the remote Sencho instance - the remote's `authMiddleware` evaluates `cookieToken || bearerToken` and the cookie (signed with the local JWT secret) was validated before the valid Bearer token, causing 401 on all proxied API calls. Fixed by stripping the `cookie` header in `proxyReq` so only the Bearer token is used for remote authentication.
- **Fixed:** `nodeContextMiddleware` blocking `/api/nodes` when `x-node-id` references a deleted/non-existent node - the nodes list endpoint must always succeed so the frontend can re-sync a stale node ID in localStorage; exempted alongside `/api/auth/`.
- **Fixed:** Remote node proxy stripping the `/api` path prefix - `remoteNodeProxy` is mounted at `app.use('/api/', ...)` so Express strips that prefix from `req.url` before `http-proxy-middleware` sees it; added `pathRewrite: (path) => '/api' + path` to restore the full path when forwarding to the remote Sencho instance (e.g. `/stats``/api/stats`). This was the root cause of all remote API calls returning the remote's SPA HTML instead of JSON.
- **Fixed:** Dashboard cards (Active Containers, Host CPU, Host RAM, Docker Network) showing stale local-node data after switching to a remote node - `HomeDashboard` polling effects now depend on `activeNode?.id` and clear state immediately on node change.
- **Fixed:** `refreshStacks` crashing with `SyntaxError` or `TypeError` when the remote proxy returns a non-JSON response (e.g., connection refused to unreachable remote node) - now checks `res.ok` before calling `res.json()` and iterates a typed `fileList` instead of the raw parsed value.
- **Fixed:** Restored Local/Remote type selector and fixed state resets in the Add Node modal - form now resets to defaults every time the dialog opens, and the title reflects the chosen type dynamically.
- **Fixed:** Remote node connection details failing to display Containers, Images, and CPU metrics - `testRemoteConnection` now fires parallel requests to `/api/stats`, `/api/system/stats`, and `/api/system/images` after auth succeeds, mapping real values into the info panel.
- **Fixed:** Suppressed `[DEP0060] DeprecationWarning: util._extend` from `http-proxy@1.18.1` - override is applied to `process.emitWarning` before the proxy instances are created, cleanly intercepting the warning at its call site without suppressing other warnings.
- **Fixed:** Backend memory leak caused by improper proxy middleware instantiation - `createProxyMiddleware` was called inside the request handler on every API call, spawning a new `http-proxy` instance (and registering new server listeners) per request. Refactored to a single globally-instantiated proxy using the `router` option for dynamic per-request target resolution.
- **Fixed:** `[DEP0060] DeprecationWarning: util._extend` deprecation eliminated as a side-effect of the above fix (deprecation was triggered on every new `http-proxy` initialisation).
- **Fixed:** Remote node authentication failures - `authMiddleware` and WebSocket upgrade handler both accept `Authorization: Bearer` tokens (Sencho-to-Sencho proxy auth).
+12 -11
View File
@@ -32,8 +32,8 @@ const execAsync = promisify(exec);
// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls
// util._extend internally. The warning fires at runtime when createProxyServer() is
// first invoked (NOT at import time), so intercepting process.emitWarning here
// before the proxy instances are created below fully prevents it.
// first invoked (NOT at import time), so intercepting process.emitWarning here -
// before the proxy instances are created below - fully prevents it.
// http-proxy has no compatible update; this suppression is intentional and safe.
const _origEmitWarning = process.emitWarning.bind(process);
(process as any).emitWarning = (warning: any, ...args: any[]) => {
@@ -359,7 +359,7 @@ const remoteNodeProxy = createProxyMiddleware<Request, Response>({
proxyReq.setHeader('Authorization', `Bearer ${node.api_token}`);
}
// Strip the ?nodeId= query param so the remote's nodeContextMiddleware
// doesn't reject the request with 404 ("Node X not found") the remote
// doesn't reject the request with 404 ("Node X not found") - the remote
// has no record of the gateway's node IDs and should treat the request
// as local. This affects endpoints like EventSource /api/containers/:id/logs
// that pass nodeId as a query param rather than the x-node-id header.
@@ -375,7 +375,7 @@ const remoteNodeProxy = createProxyMiddleware<Request, Response>({
console.error('[Proxy] Remote node error:', (err as Error).message);
// proxyRes can be either a ServerResponse (HTTP) or a raw Socket (WS/TCP errors).
// Only attempt to send an HTTP 502 if it is a proper ServerResponse with a
// headersSent flag otherwise silently drop (the socket will be destroyed).
// headersSent flag - otherwise silently drop (the socket will be destroyed).
const res = proxyRes as any;
if (typeof res?.headersSent === 'boolean' && !res.headersSent && typeof res.status === 'function') {
res.status(502).json({
@@ -418,9 +418,10 @@ const wss = new WebSocket.Server({ noServer: true });
let terminalWs: WebSocket | null = null;
// Notification push set of authenticated browser clients subscribed to real-time alerts
// Notification push - set of authenticated browser clients subscribed to real-time alerts
const notificationSubscribers = new Set<WebSocket>();
NotificationService.getInstance().setBroadcaster((notification) => {
if (notificationSubscribers.size === 0) return;
const msg = JSON.stringify({ type: 'notification', payload: notification });
for (const ws of notificationSubscribers) {
if (ws.readyState === WebSocket.OPEN) {
@@ -461,7 +462,7 @@ server.on('upgrade', async (req, socket, head) => {
const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`);
const pathname = parsedUrl.pathname;
// Notification push channel always local, never proxied to remote nodes
// Notification push channel - always local, never proxied to remote nodes
if (pathname === '/ws/notifications') {
const notifWss = new WebSocket.Server({ noServer: true });
notifWss.handleUpgrade(req, socket, head, (ws) => {
@@ -483,7 +484,7 @@ server.on('upgrade', async (req, socket, head) => {
const wsTarget = node.api_url.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws');
req.headers['authorization'] = `Bearer ${node.api_token}`;
delete req.headers['x-node-id'];
// Strip the browser's session cookie it is signed by this instance's JWT secret and
// Strip the browser's session cookie - it is signed by this instance's JWT secret and
// would fail verification on the remote. Auth is handled exclusively via the Bearer token.
delete req.headers['cookie'];
// Strip nodeId from the forwarded URL so the remote treats the request as a local one.
@@ -1096,7 +1097,7 @@ app.get('/api/logs/global', async (req: Request, res: Response) => {
}));
// Sort globally by timestamp ascending (newest bottom).
// Limit to 500 lines the client renders at most 300 rows at once, so
// Limit to 500 lines - the client renders at most 300 rows at once, so
// sending 2000 lines was wasting bandwidth and inflating JSON parse time.
allLogs.sort((a, b) => a.timestampMs - b.timestampMs);
res.json(allLogs.slice(-500));
@@ -1269,7 +1270,7 @@ app.post('/api/agents', async (req: Request, res: Response) => {
}
});
// Keys that contain auth credentials never exposed to the frontend or writable via settings API
// Keys that contain auth credentials - never exposed to the frontend or writable via settings API
const PRIVATE_SETTINGS_KEYS = new Set(['auth_username', 'auth_password_hash', 'auth_jwt_secret']);
// Strict allowlist of keys writable via the settings API (prevents overwriting auth credentials)
@@ -1286,7 +1287,7 @@ const ALLOWED_SETTING_KEYS = new Set([
'log_retention_days',
]);
// Zod schema for bulk PATCH all keys optional, present keys fully validated
// Zod schema for bulk PATCH - all keys optional, present keys fully validated
import { z } from 'zod';
const SettingsPatchSchema = z.object({
host_cpu_limit: z.coerce.number().int().min(1).max(100).transform(String),
@@ -1304,7 +1305,7 @@ const SettingsPatchSchema = z.object({
app.get('/api/settings', async (req: Request, res: Response) => {
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
// Strip auth credentials these are managed exclusively by /api/auth/* endpoints
// Strip auth credentials - these are managed exclusively by /api/auth/* endpoints
for (const key of PRIVATE_SETTINGS_KEYS) {
delete settings[key];
}
+1 -1
View File
@@ -251,7 +251,7 @@ export class DatabaseService {
stmt.run(key, value);
}
// --- System State (operational/runtime values not user-defined config) ---
// --- System State (operational/runtime values - not user-defined config) ---
public getSystemState(key: string): string | null {
const row = this.db.prepare('SELECT value FROM system_state WHERE key = ?').get(key) as { value: string } | undefined;
+4 -4
View File
@@ -109,7 +109,7 @@ async function getAuthToken(registry: string, repo: string): Promise<string | nu
// ─── Remote digest lookup ─────────────────────────────────────────────────────
// Include manifest list types so we get the fat-manifest digest for multi-arch
// images this matches what Docker stores in local RepoDigests.
// images - this matches what Docker stores in local RepoDigests.
const MANIFEST_ACCEPT = [
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.docker.distribution.manifest.v2+json',
@@ -145,7 +145,7 @@ export class ImageUpdateService {
private static readonly MANUAL_COOLDOWN_MS = 10 * 60 * 1000; // 10 min between manual triggers
private static readonly INTER_IMAGE_DELAY_MS = 300; // be polite to registries
private constructor() {}
private constructor() { }
public static getInstance(): ImageUpdateService {
if (!ImageUpdateService.instance) {
@@ -195,7 +195,7 @@ export class ImageUpdateService {
try {
const db = DatabaseService.getInstance();
// Only check local nodes remote nodes run their own instance
// Only check local nodes - remote nodes run their own instance
for (const node of db.getNodes()) {
if (node.type !== 'local' || !node.id) continue;
try {
@@ -282,7 +282,7 @@ export class ImageUpdateService {
if (!localDigest) return false; // Locally built or never pulled with a digest
const remoteDigest = await getRemoteDigest(parsed.registry, parsed.repo, parsed.tag);
if (!remoteDigest) return false; // Registry unreachable no false positives
if (!remoteDigest) return false; // Registry unreachable - no false positives
const hasUpdate = localDigest !== remoteDigest;
console.log(
+2 -2
View File
@@ -157,14 +157,14 @@ export class NodeRegistry {
const headers = { Authorization: `Bearer ${node.api_token}` };
try {
// Step 1: Verify auth. A 401 here means wrong token surface that clearly.
// Step 1: Verify auth. A 401 here means wrong token - surface that clearly.
const authRes = await axios.get(`${baseUrl}/api/auth/check`, { headers, timeout: 8000 });
if (authRes.status !== 200) throw new Error(`Unexpected status ${authRes.status}`);
db.updateNodeStatus(node.id, 'online');
// Step 2: Fetch Docker stats in parallel. Use allSettled so a slow or missing
// endpoint doesn't fail the whole test each field falls back to '-' gracefully.
// endpoint doesn't fail the whole test - each field falls back to '-' gracefully.
const [statsResult, sysResult, imagesResult] = await Promise.allSettled([
axios.get(`${baseUrl}/api/stats`, { headers, timeout: 8000 }),
axios.get(`${baseUrl}/api/system/stats`, { headers, timeout: 8000 }),
+1 -1
View File
@@ -34,7 +34,7 @@ export class NotificationService {
this.broadcaster(notification);
}
// 2. Fetch enabled agents
// 3. Fetch enabled agents
const agents = this.dbService.getEnabledAgents();
if (agents.length === 0) {
console.log('No active notification agents found. Skipping external dispatch.');
+1 -1
View File
@@ -248,7 +248,7 @@ export class TemplateService {
});
} else {
// Legacy Portainer v2 Format (Fallback for custom registries)
// The Portainer v2 spec includes a native `categories` field pass it through.
// The Portainer v2 spec includes a native `categories` field - pass it through.
this.cachedTemplates = (response.data.templates || [])
.filter((t: Template) => !!t.image && t.type === 1)
.map((t: Template) => ({ ...t, source: 'custom' }));
+3 -1
View File
@@ -19,5 +19,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
"registries": {
"@animate-ui": "https://animate-ui.com/r/{name}.json"
}
}
+3
View File
@@ -6,6 +6,9 @@
<link rel="icon" type="image/png" href="/sencho-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sencho</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&display=swap" rel="stylesheet" />
<script>
(function () {
try {
+1593 -6
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -33,10 +33,14 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"geist": "^1.7.0",
"lucide-react": "^0.575.0",
"motion": "^12.38.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-use-measure": "^2.1.7",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
+201 -167
View File
@@ -1,4 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { motion } from 'motion/react';
type Theme = 'light' | 'dark' | 'auto';
import Editor from '@monaco-editor/react';
import TerminalComponent from './Terminal';
import ErrorBoundary from './ErrorBoundary';
@@ -75,6 +78,7 @@ export default function EditorLayout() {
// the stale-closure bug that occurs when reading containerStats directly.
const rawBytesRef = useRef<Record<string, { lastRx: number; lastTx: number }>>({});
const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose');
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [newStackName, setNewStackName] = useState('');
@@ -82,13 +86,15 @@ export default function EditorLayout() {
const [isLoading, setIsLoading] = useState(false);
const [loadingAction, setLoadingAction] = useState<string | null>(null);
const [isFileLoading, setIsFileLoading] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(() => {
const saved = localStorage.getItem('sencho-theme');
if (saved !== null) {
return saved === 'dark';
}
return true; // Default to dark mode
const [theme, setTheme] = useState<Theme>(() => {
const saved = localStorage.getItem('sencho-theme') as Theme | null;
if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved;
return 'dark'; // Default to dark mode
});
const [systemDark, setSystemDark] = useState(() =>
window.matchMedia('(prefers-color-scheme: dark)').matches
);
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability'>('dashboard');
const [isEditing, setIsEditing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
@@ -117,17 +123,34 @@ export default function EditorLayout() {
setAlertSheetOpen(true);
};
// Theme toggle effect
// Listen for system dark mode changes (for 'auto' theme)
useEffect(() => {
const html = document.documentElement;
if (isDarkMode) {
html.classList.add('dark');
localStorage.setItem('sencho-theme', 'dark');
} else {
html.classList.remove('dark');
localStorage.setItem('sencho-theme', 'light');
}
}, [isDarkMode]);
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
// Apply dark class and persist theme preference
useEffect(() => {
document.documentElement.classList.toggle('dark', isDarkMode);
localStorage.setItem('sencho-theme', theme);
}, [isDarkMode, theme]);
// Force Monaco to re-measure its container after the tab switch DOM settles.
// Monaco's internal child is position:static with an explicit pixel height that
// creates a circular CSS dependency (Monaco drives card height → grid height → Monaco).
// Fix: reset Monaco to 0×0 first (breaks the cycle), then trigger a forced synchronous
// reflow so the container has its CSS-correct size before Monaco re-measures.
useEffect(() => {
const id = requestAnimationFrame(() => {
const editor = monacoEditorRef.current;
if (!editor) return;
editor.layout({ width: 0, height: 0 }); // collapse → breaks CSS circular dependency
editor.layout(); // forced reflow → measures correct container size
});
return () => cancelAnimationFrame(id);
}, [activeTab]);
const refreshStacks = async (background = false) => {
if (!background) setIsLoading(true);
@@ -162,7 +185,7 @@ export default function EditorLayout() {
}
};
// Notification WS push load history once on mount, then receive live updates
// Notification WS push - load history once on mount, then receive live updates
useEffect(() => {
fetchNotifications();
@@ -300,7 +323,7 @@ export default function EditorLayout() {
}
// Rate is derived from rawBytesRef which is never cleared on flush,
// so the delta is always accurate no stale-closure risk.
// so the delta is always accurate - no stale-closure risk.
const prevRaw = rawBytesRef.current[container.Id];
const rxRate = prevRaw ? Math.max(0, currentRx - prevRaw.lastRx) : 0;
const txRate = prevRaw ? Math.max(0, currentTx - prevRaw.lastTx) : 0;
@@ -308,7 +331,7 @@ export default function EditorLayout() {
const netIO = `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`;
// Write into the buffer ref only zero re-render cost.
// Write into the buffer ref only - zero re-render cost.
pendingStatsRef.current[container.Id] = {
cpu: cpuPercent + '%',
ram: ramUsage,
@@ -770,7 +793,7 @@ export default function EditorLayout() {
<SelectItem key={node.id} value={node.id.toString()}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full shrink-0 ${node.status === 'online' ? 'bg-green-500' :
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
}`} />
{node.name}
</div>
@@ -880,7 +903,7 @@ export default function EditorLayout() {
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top Header Bar */}
<div className="h-16 flex items-center justify-between px-6 border-b border-border gap-4">
{/* Node Context Pill visible only when a remote node is active */}
{/* Node Context Pill - visible only when a remote node is active */}
<div className="flex-shrink-0">
{activeNode?.type === 'remote' ? (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm font-medium">
@@ -895,156 +918,156 @@ export default function EditorLayout() {
)}
</div>
<div className="flex items-center gap-4">
{/* Home Button */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => {
setSelectedFile(null);
setContent('');
setOriginalContent('');
setEnvContent('');
setOriginalEnvContent('');
setEnvFiles([]);
setSelectedEnvFile('');
setEnvExists(false);
setContainers([]);
setIsEditing(false);
setActiveView('dashboard');
}}
title="Go to Home Dashboard"
>
<Home className="w-4 h-4 mr-2" />
Home
</Button>
{/* Console Toggle */}
<Button
variant={activeView === 'host-console' ? 'default' : 'outline'}
size="sm"
className="rounded-lg"
onClick={() => setActiveView(activeView === 'host-console' ? (selectedFile ? 'editor' : 'dashboard') : 'host-console')}
>
<Terminal className="w-4 h-4 mr-2" />
Console
</Button>
{/* Resources Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setActiveView('resources')}
title="System Resources"
>
<HardDrive className="w-4 h-4 mr-2" />
Resources
</Button>
{/* App Store Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setActiveView('templates')}
title="App Store"
>
<CloudDownload className="w-4 h-4 mr-2" />
App Store
</Button>
{/* Global Observability Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setActiveView('global-observability')}
title="Global Logs"
>
<Activity className="w-4 h-4 mr-2" />
Logs
</Button>
{/* Home Button */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => {
setSelectedFile(null);
setContent('');
setOriginalContent('');
setEnvContent('');
setOriginalEnvContent('');
setEnvFiles([]);
setSelectedEnvFile('');
setEnvExists(false);
setContainers([]);
setIsEditing(false);
setActiveView('dashboard');
}}
title="Go to Home Dashboard"
>
<Home className="w-4 h-4 mr-2" />
Home
</Button>
{/* Console Toggle */}
<Button
variant={activeView === 'host-console' ? 'default' : 'outline'}
size="sm"
className="rounded-lg"
onClick={() => setActiveView(activeView === 'host-console' ? (selectedFile ? 'editor' : 'dashboard') : 'host-console')}
>
<Terminal className="w-4 h-4 mr-2" />
Console
</Button>
{/* Resources Toggle */}
<Button
variant={activeView === 'resources' ? 'default' : 'outline'}
size="sm"
className="rounded-lg"
onClick={() => setActiveView(activeView === 'resources' ? (selectedFile ? 'editor' : 'dashboard') : 'resources')}
title="System Resources"
>
<HardDrive className="w-4 h-4 mr-2" />
Resources
</Button>
{/* App Store Toggle */}
<Button
variant={activeView === 'templates' ? 'default' : 'outline'}
size="sm"
className="rounded-lg"
onClick={() => setActiveView(activeView === 'templates' ? (selectedFile ? 'editor' : 'dashboard') : 'templates')}
title="App Store"
>
<CloudDownload className="w-4 h-4 mr-2" />
App Store
</Button>
{/* Global Observability Toggle */}
<Button
variant={activeView === 'global-observability' ? 'default' : 'outline'}
size="sm"
className="rounded-lg"
onClick={() => setActiveView(activeView === 'global-observability' ? (selectedFile ? 'editor' : 'dashboard') : 'global-observability')}
title="Global Logs"
>
<Activity className="w-4 h-4 mr-2" />
Logs
</Button>
{/* Settings Modal Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setSettingsModalOpen(true)}
title="Notification Settings"
>
<Settings className="w-4 h-4 mr-2" />
Settings
</Button>
{/* Settings Modal Toggle */}
<Button
variant="outline"
size="sm"
className="rounded-lg"
onClick={() => setSettingsModalOpen(true)}
title="Notification Settings"
>
<Settings className="w-4 h-4 mr-2" />
Settings
</Button>
{/* Notifications Popover */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="rounded-lg relative" title="Notifications">
<Bell className="w-4 h-4" />
{notifications.filter(n => !n.is_read).length > 0 && (
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="flex items-center justify-between p-4 border-b">
<h4 className="font-semibold">Notifications</h4>
<div className="flex gap-2">
{/* Notifications Popover */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="rounded-lg relative" title="Notifications">
<Bell className="w-4 h-4" />
{notifications.filter(n => !n.is_read).length > 0 && (
<Button variant="ghost" size="sm" onClick={markAllRead} className="h-auto p-0 text-xs">
Mark all as read
</Button>
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span>
)}
{notifications.length > 0 && (
<Button variant="ghost" size="sm" onClick={clearAllNotifications} className="h-auto p-0 text-xs text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="w-3 h-3 mr-1" />
Clear all
</Button>
)}
</div>
</div>
<ScrollArea className="h-80">
{notifications.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground text-center">No notifications</div>
) : (
<div className="flex flex-col">
{notifications.map((notif: any) => (
<div key={notif.id} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'} relative group`}>
<div className="flex items-center gap-2 mb-1 pr-6">
<Badge variant={notif.level === 'error' ? 'destructive' : notif.level === 'warning' ? 'secondary' : 'default'} className="text-[10px] uppercase">
{notif.level}
</Badge>
<span className="text-xs text-muted-foreground ml-auto">
{new Date(notif.timestamp).toLocaleString()}
</span>
</div>
<p className="font-medium pr-6">{notif.message}</p>
{/* Delete individual notification button */}
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
deleteNotification(notif.id);
}}
>
<X className="w-3 h-3" />
</Button>
</div>
))}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="flex items-center justify-between p-4 border-b">
<h4 className="font-semibold">Notifications</h4>
<div className="flex gap-2">
{notifications.filter(n => !n.is_read).length > 0 && (
<Button variant="ghost" size="sm" onClick={markAllRead} className="h-auto p-0 text-xs">
Mark all as read
</Button>
)}
{notifications.length > 0 && (
<Button variant="ghost" size="sm" onClick={clearAllNotifications} className="h-auto p-0 text-xs text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="w-3 h-3 mr-1" />
Clear all
</Button>
)}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
</div>
<ScrollArea className="h-80">
{notifications.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground text-center">No notifications</div>
) : (
<div className="flex flex-col">
{notifications.map((notif: any) => (
<div key={notif.id} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'} relative group`}>
<div className="flex items-center gap-2 mb-1 pr-6">
<Badge variant={notif.level === 'error' ? 'destructive' : notif.level === 'warning' ? 'secondary' : 'default'} className="text-[10px] uppercase">
{notif.level}
</Badge>
<span className="text-xs text-muted-foreground ml-auto">
{new Date(notif.timestamp).toLocaleString()}
</span>
</div>
<p className="font-medium pr-6">{notif.message}</p>
{/* Delete individual notification button */}
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
deleteNotification(notif.id);
}}
>
<X className="w-3 h-3" />
</Button>
</div>
))}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
</div>{/* end right-side buttons */}
</div>
{/* Main Workspace */}
<div className="flex-1 overflow-y-auto p-6">
<div key={activeView} className="flex-1 overflow-y-auto p-6 animate-fade-up">
{activeView === 'templates' ? (
<AppStoreView onDeploySuccess={(stackName) => { refreshStacks(); loadFile(stackName); }} />
) : activeView === 'resources' ? (
@@ -1234,8 +1257,18 @@ export default function EditorLayout() {
<div className="flex items-center gap-4">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env')}>
<TabsList className="bg-muted">
<TabsTrigger value="compose" className="rounded-lg">compose.yaml</TabsTrigger>
<TabsTrigger value="env" disabled={!envExists} className="rounded-lg">.env</TabsTrigger>
<TabsTrigger value="compose" className="relative rounded-lg data-[state=active]:bg-transparent data-[state=active]:shadow-none">
{activeTab === 'compose' && (
<motion.div layoutId="editor-tab-indicator" className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: 'spring', stiffness: 400, damping: 30 }} />
)}
<span className="relative z-10">compose.yaml</span>
</TabsTrigger>
<TabsTrigger value="env" disabled={!envExists} className="relative rounded-lg data-[state=active]:bg-transparent data-[state=active]:shadow-none">
{activeTab === 'env' && (
<motion.div layoutId="editor-tab-indicator" className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: 'spring', stiffness: 400, damping: 30 }} />
)}
<span className="relative z-10">.env</span>
</TabsTrigger>
</TabsList>
</Tabs>
@@ -1286,13 +1319,14 @@ export default function EditorLayout() {
</span>
</div>
)}
<div className="flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-hidden">
{!isFileLoading && (
<Editor
height="100%"
language={activeTab === 'compose' ? 'yaml' : 'plaintext'}
theme={isDarkMode ? 'vs-dark' : 'vs'}
value={activeTab === 'compose' ? safeContent : safeEnvContent}
onMount={(editor) => { monacoEditorRef.current = editor; }}
onChange={(value) => {
if (!isEditing) return; // Prevent changes in view mode
if (activeTab === 'compose') {
@@ -1369,8 +1403,8 @@ export default function EditorLayout() {
<SettingsModal
isOpen={settingsModalOpen}
onClose={() => setSettingsModalOpen(false)}
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
theme={theme}
setTheme={setTheme}
/>
{/* Stack Alert Sheet */}
+1 -1
View File
@@ -161,7 +161,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
<span>Host Console</span>
{activeNode && (
<span className="text-muted-foreground font-normal text-sm">
{activeNode.name}
- {activeNode.name}
</span>
)}
{stackName && (
+2 -2
View File
@@ -247,13 +247,13 @@ export function NodeManager() {
<SelectItem value="local">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4" />
Local Docker socket on this machine
Local - Docker socket on this machine
</div>
</SelectItem>
<SelectItem value="remote">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4" />
Remote another Sencho instance
Remote - another Sencho instance
</div>
</SelectItem>
</SelectContent>
+13 -13
View File
@@ -191,7 +191,7 @@ export default function ResourcesView() {
<HardDrive className="w-6 h-6" />
<h1 className="text-2xl font-bold">Resources Hub</h1>
{activeNode?.type === 'remote' && (
<span className="text-sm font-normal text-muted-foreground"> {activeNode.name}</span>
<span className="text-sm font-normal text-muted-foreground">- {activeNode.name}</span>
)}
</div>
@@ -244,21 +244,21 @@ export default function ResourcesView() {
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-center">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Button variant="outline" className="h-24 flex flex-col gap-2 hover:bg-muted/50 transition-colors" onClick={() => setConfirmPruneType('images')}>
<PackageMinus className="w-8 h-8 text-blue-500" />
<span className="text-xs font-semibold">Prune Unused Images</span>
<Button variant="outline" className="h-24 flex flex-col gap-2 hover:bg-muted/50 transition-colors whitespace-normal" onClick={() => setConfirmPruneType('images')}>
<PackageMinus className="w-8 h-8 text-blue-500 shrink-0" />
<span className="text-xs font-semibold text-center leading-tight">Prune Unused Images</span>
</Button>
<Button variant="outline" className="h-24 flex flex-col gap-2 hover:bg-muted/50 transition-colors" onClick={() => setConfirmPruneType('volumes')}>
<HardDrive className="w-8 h-8 text-purple-500" />
<span className="text-xs font-semibold">Prune Unused Volumes</span>
<Button variant="outline" className="h-24 flex flex-col gap-2 hover:bg-muted/50 transition-colors whitespace-normal" onClick={() => setConfirmPruneType('volumes')}>
<HardDrive className="w-8 h-8 text-purple-500 shrink-0" />
<span className="text-xs font-semibold text-center leading-tight">Prune Unused Volumes</span>
</Button>
<Button variant="outline" className="h-24 flex flex-col gap-2 hover:bg-muted/50 transition-colors" onClick={() => setConfirmPruneType('networks')}>
<Network className="w-8 h-8 text-green-500" />
<span className="text-xs font-semibold">Prune Dead Networks</span>
<Button variant="outline" className="h-24 flex flex-col gap-2 hover:bg-muted/50 transition-colors whitespace-normal" onClick={() => setConfirmPruneType('networks')}>
<Network className="w-8 h-8 text-green-500 shrink-0" />
<span className="text-xs font-semibold text-center leading-tight">Prune Dead Networks</span>
</Button>
<Button variant="outline" className="h-24 flex flex-col gap-2 hover:bg-muted/50 transition-colors" onClick={() => setConfirmPruneType('containers')}>
<MonitorX className="w-8 h-8 text-orange-500" />
<span className="text-xs font-semibold">Purge Ghost Containers</span>
<Button variant="outline" className="h-24 flex flex-col gap-2 hover:bg-muted/50 transition-colors whitespace-normal" onClick={() => setConfirmPruneType('containers')}>
<MonitorX className="w-8 h-8 text-orange-500 shrink-0" />
<span className="text-xs font-semibold text-center leading-tight">Purge Ghost Containers</span>
</Button>
</div>
</CardContent>
+71 -42
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { motion } from 'motion/react';
import {
Dialog,
DialogContent,
@@ -14,7 +15,7 @@ import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server, Package, RefreshCw, Database, Info } from 'lucide-react';
import { Shield, Activity, Bell, Palette, Moon, Sun, Monitor, Code, Server, Package, RefreshCw, Database, Info } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { NodeManager } from './NodeManager';
import { useNodes } from '@/context/NodeContext';
@@ -41,11 +42,13 @@ interface PatchableSettings {
type SectionId = 'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes' | 'appstore';
type Theme = 'light' | 'dark' | 'auto';
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
isDarkMode: boolean;
setIsDarkMode: (mode: boolean) => void;
theme: Theme;
setTheme: (theme: Theme) => void;
}
const DEFAULT_SETTINGS: PatchableSettings = {
@@ -61,7 +64,7 @@ const DEFAULT_SETTINGS: PatchableSettings = {
log_retention_days: '30',
};
export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) {
export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModalProps) {
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const [activeSection, setActiveSection] = useState<SectionId>('account');
@@ -73,6 +76,9 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
}
}, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps
// Notification tab state (controlled for sliding indicator)
const [notifTab, setNotifTab] = useState<'discord' | 'slack' | 'webhook'>('discord');
// Auth State
const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
@@ -83,7 +89,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
webhook: { type: 'webhook', url: '', enabled: false },
});
// Settings state all user-configurable keys (no auth keys)
// Settings state - all user-configurable keys (no auth keys)
const [settings, setSettings] = useState<PatchableSettings>({ ...DEFAULT_SETTINGS });
// Track server state to detect unsaved changes without causing re-renders
@@ -100,17 +106,17 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
// Unsaved changes indicators per section (compared against server ref)
const hasSystemChanges =
settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit ||
settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit ||
settings.host_disk_limit !== serverSettingsRef.current.host_disk_limit ||
settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit ||
settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit ||
settings.host_disk_limit !== serverSettingsRef.current.host_disk_limit ||
settings.docker_janitor_gb !== serverSettingsRef.current.docker_janitor_gb ||
settings.global_crash !== serverSettingsRef.current.global_crash;
settings.global_crash !== serverSettingsRef.current.global_crash;
const hasDeveloperChanges =
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh ||
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh ||
settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours ||
settings.log_retention_days !== serverSettingsRef.current.log_retention_days;
settings.log_retention_days !== serverSettingsRef.current.log_retention_days;
useEffect(() => {
if (isOpen) {
@@ -140,7 +146,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
try {
// Fetch per-node settings from the active node (system limits etc.)
const nodeRes = await apiFetch('/settings');
// Always fetch developer/UI preferences from local these control
// Always fetch developer/UI preferences from local - these control
// this Sencho instance's behaviour and must never be proxied to remote
const localRes = isRemote ? await apiFetch('/settings', { localOnly: true }) : nodeRes;
@@ -151,17 +157,17 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
const safe: PatchableSettings = {
// Per-node: read from active node
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
template_registry_url: nodeData.template_registry_url ?? '',
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
template_registry_url: nodeData.template_registry_url ?? '',
// Local-only: always read from local node
global_logs_refresh: (localData.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh,
developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
global_logs_refresh: (localData.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh,
developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
};
setSettings(safe);
serverSettingsRef.current = { ...safe };
@@ -201,22 +207,22 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
const saveSystemSettings = async () => {
const ok = await patchSettings({
host_cpu_limit: settings.host_cpu_limit,
host_ram_limit: settings.host_ram_limit,
host_disk_limit: settings.host_disk_limit,
host_cpu_limit: settings.host_cpu_limit,
host_ram_limit: settings.host_ram_limit,
host_disk_limit: settings.host_disk_limit,
docker_janitor_gb: settings.docker_janitor_gb,
global_crash: settings.global_crash,
global_crash: settings.global_crash,
}, setIsSavingSystem);
if (ok) toast.success('System limits saved.');
};
const saveDeveloperSettings = async () => {
// Developer/UI preferences are local-only never proxy to remote node
// Developer/UI preferences are local-only - never proxy to remote node
const ok = await patchSettings({
developer_mode: settings.developer_mode,
global_logs_refresh: settings.global_logs_refresh,
developer_mode: settings.developer_mode,
global_logs_refresh: settings.global_logs_refresh,
metrics_retention_hours: settings.metrics_retention_hours,
log_retention_days: settings.log_retention_days,
log_retention_days: settings.log_retention_days,
}, setIsSavingDeveloper, true);
if (ok) toast.success('Developer settings saved.');
};
@@ -571,11 +577,26 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
<h3 className="text-lg font-semibold tracking-tight">Notifications & Alerts</h3>
<p className="text-sm text-muted-foreground">Configure external integrations for crash alerts.</p>
</div>
<Tabs defaultValue="discord" className="w-full">
<TabsList className="grid w-full grid-cols-3 mb-4">
<TabsTrigger value="discord">Discord</TabsTrigger>
<TabsTrigger value="slack">Slack</TabsTrigger>
<TabsTrigger value="webhook">Webhook</TabsTrigger>
<Tabs value={notifTab} onValueChange={(v) => setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full">
<TabsList className="w-full mb-4 grid grid-cols-3">
<TabsTrigger value="discord" className="relative data-[state=active]:bg-transparent data-[state=active]:shadow-none">
{notifTab === 'discord' && (
<motion.div layoutId="notif-tab-indicator" className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: 'spring', stiffness: 350, damping: 30 }} />
)}
<span className="relative z-10">Discord</span>
</TabsTrigger>
<TabsTrigger value="slack" className="relative data-[state=active]:bg-transparent data-[state=active]:shadow-none">
{notifTab === 'slack' && (
<motion.div layoutId="notif-tab-indicator" className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: 'spring', stiffness: 350, damping: 30 }} />
)}
<span className="relative z-10">Slack</span>
</TabsTrigger>
<TabsTrigger value="webhook" className="relative data-[state=active]:bg-transparent data-[state=active]:shadow-none">
{notifTab === 'webhook' && (
<motion.div layoutId="notif-tab-indicator" className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: 'spring', stiffness: 350, damping: 30 }} />
)}
<span className="relative z-10">Webhook</span>
</TabsTrigger>
</TabsList>
<TabsContent value="discord">{renderAgentTab('discord', 'Discord')}</TabsContent>
<TabsContent value="slack">{renderAgentTab('slack', 'Slack')}</TabsContent>
@@ -590,23 +611,31 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
<h3 className="text-lg font-semibold tracking-tight">Appearance</h3>
<p className="text-sm text-muted-foreground">Customize Sencho's visual theme.</p>
</div>
<div className="flex items-center space-x-4 mt-6">
<div className="flex items-center gap-3 mt-6 flex-wrap">
<Button
variant={!isDarkMode ? 'default' : 'outline'}
variant={theme === 'light' ? 'default' : 'outline'}
className="w-32 h-20 flex flex-col gap-2 rounded-xl"
onClick={() => setIsDarkMode(false)}
onClick={() => setTheme('light')}
>
<Sun className="w-6 h-6" />
Light
</Button>
<Button
variant={isDarkMode ? 'default' : 'outline'}
variant={theme === 'dark' ? 'default' : 'outline'}
className="w-32 h-20 flex flex-col gap-2 rounded-xl"
onClick={() => setIsDarkMode(true)}
onClick={() => setTheme('dark')}
>
<Moon className="w-6 h-6" />
Dark
</Button>
<Button
variant={theme === 'auto' ? 'default' : 'outline'}
className="w-32 h-20 flex flex-col gap-2 rounded-xl"
onClick={() => setTheme('auto')}
>
<Monitor className="w-6 h-6" />
Auto
</Button>
</div>
</div>
)}
@@ -670,7 +699,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
</SelectContent>
</Select>
{settings.developer_mode === '1' && (
<p className="text-xs text-amber-500">SSE streaming is active polling rate is overridden.</p>
<p className="text-xs text-amber-500">SSE streaming is active - polling rate is overridden.</p>
)}
</div>
</div>
@@ -750,7 +779,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
<div className="space-y-1">
<Label className="text-base">Default Registry</Label>
<p className="text-xs text-muted-foreground">
LinuxServer.io <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
LinuxServer.io - <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
</p>
<p className="text-xs text-muted-foreground">Used when no custom registry is set.</p>
</div>
@@ -0,0 +1,96 @@
'use client';
import * as React from 'react';
import { motion, isMotionComponent, type HTMLMotionProps } from 'motion/react';
import { cn } from '@/lib/utils';
type AnyProps = Record<string, unknown>;
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<
HTMLMotionProps<keyof HTMLElementTagNameMap>,
'ref'
> & { ref?: React.Ref<T> };
type WithAsChild<Base extends object> =
| (Base & { asChild: true; children: React.ReactElement })
| (Base & { asChild?: false | undefined });
type SlotProps<T extends HTMLElement = HTMLElement> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any;
} & DOMMotionProps<T>;
function mergeRefs<T>(
...refs: (React.Ref<T> | undefined)[]
): React.RefCallback<T> {
return (node) => {
refs.forEach((ref) => {
if (!ref) return;
if (typeof ref === 'function') {
ref(node);
} else {
(ref as React.RefObject<T | null>).current = node;
}
});
};
}
function mergeProps<T extends HTMLElement>(
childProps: AnyProps,
slotProps: DOMMotionProps<T>,
): AnyProps {
const merged: AnyProps = { ...childProps, ...slotProps };
if (childProps.className || slotProps.className) {
merged.className = cn(
childProps.className as string,
slotProps.className as string,
);
}
if (childProps.style || slotProps.style) {
merged.style = {
...(childProps.style as React.CSSProperties),
...(slotProps.style as React.CSSProperties),
};
}
return merged;
}
function Slot<T extends HTMLElement = HTMLElement>({
children,
ref,
...props
}: SlotProps<T>) {
const isAlreadyMotion =
typeof children.type === 'object' &&
children.type !== null &&
isMotionComponent(children.type);
const Base = React.useMemo(
() =>
isAlreadyMotion
? (children.type as React.ElementType)
: motion.create(children.type as React.ElementType),
[isAlreadyMotion, children.type],
);
if (!React.isValidElement(children)) return null;
const { ref: childRef, ...childProps } = children.props as AnyProps;
const mergedProps = mergeProps(childProps, props);
return (
<Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />
);
}
export {
Slot,
type SlotProps,
type WithAsChild,
type DOMMotionProps,
type AnyProps,
};
@@ -0,0 +1,55 @@
'use client';
import * as React from 'react';
import {
motion,
type HTMLMotionProps,
type LegacyAnimationControls,
type TargetAndTransition,
type Transition,
} from 'motion/react';
import { useAutoHeight } from '@/hooks/use-auto-height';
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
type AutoHeightProps = WithAsChild<
{
children: React.ReactNode;
deps?: React.DependencyList;
animate?: TargetAndTransition | LegacyAnimationControls;
transition?: Transition;
} & Omit<HTMLMotionProps<'div'>, 'animate'>
>;
function AutoHeight({
children,
deps = [],
transition = {
type: 'spring',
stiffness: 300,
damping: 30,
bounce: 0,
restDelta: 0.01,
},
style,
animate,
asChild = false,
...props
}: AutoHeightProps) {
const { ref, height } = useAutoHeight<HTMLDivElement>(deps);
const Comp = asChild ? Slot : motion.div;
return (
<Comp
style={{ overflow: 'hidden', ...style }}
animate={{ height, ...animate }}
transition={transition}
{...props}
>
<div ref={ref}>{children}</div>
</Comp>
);
}
export { AutoHeight, type AutoHeightProps };
@@ -0,0 +1,93 @@
'use client';
import * as React from 'react';
import { motion, type HTMLMotionProps } from 'motion/react';
import {
useIsInView,
type UseIsInViewOptions,
} from '@/hooks/use-is-in-view';
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
type FadeProps = WithAsChild<
{
children?: React.ReactNode;
delay?: number;
initialOpacity?: number;
opacity?: number;
ref?: React.Ref<HTMLElement>;
} & UseIsInViewOptions &
HTMLMotionProps<'div'>
>;
function Fade({
ref,
transition = { type: 'spring', stiffness: 200, damping: 20 },
delay = 0,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
initialOpacity = 0,
opacity = 1,
asChild = false,
...props
}: FadeProps) {
const { ref: localRef, isInView } = useIsInView(
ref as React.Ref<HTMLElement>,
{
inView,
inViewOnce,
inViewMargin,
},
);
const Component = asChild ? Slot : motion.div;
return (
<Component
ref={localRef as React.Ref<HTMLDivElement>}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
exit="hidden"
variants={{
hidden: { opacity: initialOpacity },
visible: { opacity },
}}
transition={{
...transition,
delay: (transition?.delay ?? 0) + delay / 1000,
}}
{...props}
/>
);
}
type FadeListProps = Omit<FadeProps, 'children'> & {
children: React.ReactElement | React.ReactElement[];
holdDelay?: number;
};
function Fades({
children,
delay = 0,
holdDelay = 0,
...props
}: FadeListProps) {
const array = React.Children.toArray(children) as React.ReactElement[];
return (
<>
{array.map((child, index) => (
<Fade
key={child.key ?? index}
delay={delay + index * holdDelay}
{...props}
>
{child}
</Fade>
))}
</>
);
}
export { Fade, Fades, type FadeProps, type FadeListProps };
@@ -0,0 +1,640 @@
'use client';
import * as React from 'react';
import { AnimatePresence, motion, type Transition } from 'motion/react';
import { cn } from '@/lib/utils';
type HighlightMode = 'children' | 'parent';
type Bounds = {
top: number;
left: number;
width: number;
height: number;
};
const DEFAULT_BOUNDS_OFFSET: Bounds = {
top: 0,
left: 0,
width: 0,
height: 0,
};
type HighlightContextType<T extends string> = {
as?: keyof HTMLElementTagNameMap;
mode: HighlightMode;
activeValue: T | null;
setActiveValue: (value: T | null) => void;
setBounds: (bounds: DOMRect) => void;
clearBounds: () => void;
id: string;
hover: boolean;
click: boolean;
className?: string;
style?: React.CSSProperties;
activeClassName?: string;
setActiveClassName: (className: string) => void;
transition?: Transition;
disabled?: boolean;
enabled?: boolean;
exitDelay?: number;
forceUpdateBounds?: boolean;
};
const HighlightContext = React.createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
HighlightContextType<any> | undefined
>(undefined);
function useHighlight<T extends string>(): HighlightContextType<T> {
const context = React.useContext(HighlightContext);
if (!context) {
throw new Error('useHighlight must be used within a HighlightProvider');
}
return context as unknown as HighlightContextType<T>;
}
type BaseHighlightProps<T extends React.ElementType = 'div'> = {
as?: T;
ref?: React.Ref<HTMLDivElement>;
mode?: HighlightMode;
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string | null) => void;
className?: string;
style?: React.CSSProperties;
transition?: Transition;
hover?: boolean;
click?: boolean;
disabled?: boolean;
enabled?: boolean;
exitDelay?: number;
};
type ParentModeHighlightProps = {
boundsOffset?: Partial<Bounds>;
containerClassName?: string;
forceUpdateBounds?: boolean;
};
type ControlledParentModeHighlightProps<T extends React.ElementType = 'div'> =
BaseHighlightProps<T> &
ParentModeHighlightProps & {
mode: 'parent';
controlledItems: true;
children: React.ReactNode;
};
type ControlledChildrenModeHighlightProps<T extends React.ElementType = 'div'> =
BaseHighlightProps<T> & {
mode?: 'children' | undefined;
controlledItems: true;
children: React.ReactNode;
};
type UncontrolledParentModeHighlightProps<T extends React.ElementType = 'div'> =
BaseHighlightProps<T> &
ParentModeHighlightProps & {
mode: 'parent';
controlledItems?: false;
itemsClassName?: string;
children: React.ReactElement | React.ReactElement[];
};
type UncontrolledChildrenModeHighlightProps<
T extends React.ElementType = 'div',
> = BaseHighlightProps<T> & {
mode?: 'children';
controlledItems?: false;
itemsClassName?: string;
children: React.ReactElement | React.ReactElement[];
};
type HighlightProps<T extends React.ElementType = 'div'> =
| ControlledParentModeHighlightProps<T>
| ControlledChildrenModeHighlightProps<T>
| UncontrolledParentModeHighlightProps<T>
| UncontrolledChildrenModeHighlightProps<T>;
function Highlight<T extends React.ElementType = 'div'>({
ref,
...props
}: HighlightProps<T>) {
const {
as: Component = 'div',
children,
value,
defaultValue,
onValueChange,
className,
style,
transition = { type: 'spring', stiffness: 350, damping: 35 },
hover = false,
click = true,
enabled = true,
controlledItems,
disabled = false,
exitDelay = 200,
mode = 'children',
} = props;
const localRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
const propsBoundsOffset = (props as ParentModeHighlightProps)?.boundsOffset;
const boundsOffset = propsBoundsOffset ?? DEFAULT_BOUNDS_OFFSET;
const boundsOffsetTop = boundsOffset.top ?? 0;
const boundsOffsetLeft = boundsOffset.left ?? 0;
const boundsOffsetWidth = boundsOffset.width ?? 0;
const boundsOffsetHeight = boundsOffset.height ?? 0;
const boundsOffsetRef = React.useRef({
top: boundsOffsetTop,
left: boundsOffsetLeft,
width: boundsOffsetWidth,
height: boundsOffsetHeight,
});
React.useEffect(() => {
boundsOffsetRef.current = {
top: boundsOffsetTop,
left: boundsOffsetLeft,
width: boundsOffsetWidth,
height: boundsOffsetHeight,
};
}, [
boundsOffsetTop,
boundsOffsetLeft,
boundsOffsetWidth,
boundsOffsetHeight,
]);
const [activeValue, setActiveValue] = React.useState<string | null>(
value ?? defaultValue ?? null,
);
const [boundsState, setBoundsState] = React.useState<Bounds | null>(null);
const [activeClassNameState, setActiveClassNameState] =
React.useState<string>('');
const safeSetActiveValue = (id: string | null) => {
setActiveValue((prev) => {
if (prev !== id) {
onValueChange?.(id);
return id;
}
return prev;
});
};
const safeSetBoundsRef = React.useRef<
((bounds: DOMRect) => void) | undefined
>(undefined);
React.useEffect(() => {
safeSetBoundsRef.current = (bounds: DOMRect) => {
if (!localRef.current) return;
const containerRect = localRef.current.getBoundingClientRect();
const offset = boundsOffsetRef.current;
const newBounds: Bounds = {
top: bounds.top - containerRect.top + offset.top,
left: bounds.left - containerRect.left + offset.left,
width: bounds.width + offset.width,
height: bounds.height + offset.height,
};
setBoundsState((prev) => {
if (
prev &&
prev.top === newBounds.top &&
prev.left === newBounds.left &&
prev.width === newBounds.width &&
prev.height === newBounds.height
) {
return prev;
}
return newBounds;
});
};
});
const safeSetBounds = (bounds: DOMRect) => {
safeSetBoundsRef.current?.(bounds);
};
const clearBounds = React.useCallback(() => {
setBoundsState((prev) => (prev === null ? prev : null));
}, []);
React.useEffect(() => {
if (value !== undefined) setActiveValue(value);
else if (defaultValue !== undefined) setActiveValue(defaultValue);
}, [value, defaultValue]);
const id = React.useId();
React.useEffect(() => {
if (mode !== 'parent') return;
const container = localRef.current;
if (!container) return;
const onScroll = () => {
if (!activeValue) return;
const activeEl = container.querySelector<HTMLElement>(
`[data-value="${activeValue}"][data-highlight="true"]`,
);
if (activeEl)
safeSetBoundsRef.current?.(activeEl.getBoundingClientRect());
};
container.addEventListener('scroll', onScroll, { passive: true });
return () => container.removeEventListener('scroll', onScroll);
}, [mode, activeValue]);
const render = (children: React.ReactNode) => {
if (mode === 'parent') {
return (
<Component
ref={localRef}
data-slot="motion-highlight-container"
style={{ position: 'relative', zIndex: 1 }}
className={(props as ParentModeHighlightProps)?.containerClassName}
>
<AnimatePresence initial={false} mode="wait">
{boundsState && (
<motion.div
data-slot="motion-highlight"
animate={{
top: boundsState.top,
left: boundsState.left,
width: boundsState.width,
height: boundsState.height,
opacity: 1,
}}
initial={{
top: boundsState.top,
left: boundsState.left,
width: boundsState.width,
height: boundsState.height,
opacity: 0,
}}
exit={{
opacity: 0,
transition: {
...transition,
delay: (transition?.delay ?? 0) + (exitDelay ?? 0) / 1000,
},
}}
transition={transition}
style={{ position: 'absolute', zIndex: 0, ...style }}
className={cn(className, activeClassNameState)}
/>
)}
</AnimatePresence>
{children}
</Component>
);
}
return children;
};
return (
<HighlightContext.Provider
value={{
mode,
activeValue,
setActiveValue: safeSetActiveValue,
id,
hover,
click,
className,
style,
transition,
disabled,
enabled,
exitDelay,
setBounds: safeSetBounds,
clearBounds,
activeClassName: activeClassNameState,
setActiveClassName: setActiveClassNameState,
forceUpdateBounds: (props as ParentModeHighlightProps)
?.forceUpdateBounds,
}}
>
{enabled
? controlledItems
? render(children)
: render(
React.Children.map(children, (child, index) => (
<HighlightItem key={index} className={props?.itemsClassName}>
{child}
</HighlightItem>
)),
)
: children}
</HighlightContext.Provider>
);
}
function getNonOverridingDataAttributes(
element: React.ReactElement,
dataAttributes: Record<string, unknown>,
): Record<string, unknown> {
return Object.keys(dataAttributes).reduce<Record<string, unknown>>(
(acc, key) => {
if ((element.props as Record<string, unknown>)[key] === undefined) {
acc[key] = dataAttributes[key];
}
return acc;
},
{},
);
}
type ExtendedChildProps = React.ComponentProps<'div'> & {
id?: string;
ref?: React.Ref<HTMLElement>;
'data-active'?: string;
'data-value'?: string;
'data-disabled'?: boolean;
'data-highlight'?: boolean;
'data-slot'?: string;
};
type HighlightItemProps<T extends React.ElementType = 'div'> =
React.ComponentProps<T> & {
as?: T;
children: React.ReactElement;
id?: string;
value?: string;
className?: string;
style?: React.CSSProperties;
transition?: Transition;
activeClassName?: string;
disabled?: boolean;
exitDelay?: number;
asChild?: boolean;
forceUpdateBounds?: boolean;
};
function HighlightItem<T extends React.ElementType>({
ref,
as,
children,
id,
value,
className,
style,
transition,
disabled = false,
activeClassName,
exitDelay,
asChild = false,
forceUpdateBounds,
...props
}: HighlightItemProps<T>) {
const itemId = React.useId();
const {
activeValue,
setActiveValue,
mode,
setBounds,
clearBounds,
hover,
click,
enabled,
className: contextClassName,
style: contextStyle,
transition: contextTransition,
id: contextId,
disabled: contextDisabled,
exitDelay: contextExitDelay,
forceUpdateBounds: contextForceUpdateBounds,
setActiveClassName,
} = useHighlight();
const Component = as ?? 'div';
const element = children as React.ReactElement<ExtendedChildProps>;
const childValue =
id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId;
const isActive = activeValue === childValue;
const isDisabled = disabled === undefined ? contextDisabled : disabled;
const itemTransition = transition ?? contextTransition;
const localRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
const refCallback = React.useCallback((node: HTMLElement | null) => {
localRef.current = node as HTMLDivElement;
}, []);
React.useEffect(() => {
if (mode !== 'parent') return;
let rafId: number;
let previousBounds: Bounds | null = null;
const shouldUpdateBounds =
forceUpdateBounds === true ||
(contextForceUpdateBounds && forceUpdateBounds !== false);
const updateBounds = () => {
if (!localRef.current) return;
const bounds = localRef.current.getBoundingClientRect();
if (shouldUpdateBounds) {
if (
previousBounds &&
previousBounds.top === bounds.top &&
previousBounds.left === bounds.left &&
previousBounds.width === bounds.width &&
previousBounds.height === bounds.height
) {
rafId = requestAnimationFrame(updateBounds);
return;
}
previousBounds = bounds;
rafId = requestAnimationFrame(updateBounds);
}
setBounds(bounds);
};
if (isActive) {
updateBounds();
setActiveClassName(activeClassName ?? '');
} else if (!activeValue) clearBounds();
if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId);
}, [
mode,
isActive,
activeValue,
setBounds,
clearBounds,
activeClassName,
setActiveClassName,
forceUpdateBounds,
contextForceUpdateBounds,
]);
if (!React.isValidElement(children)) return children;
const dataAttributes = {
'data-active': isActive ? 'true' : 'false',
'aria-selected': isActive,
'data-disabled': isDisabled,
'data-value': childValue,
'data-highlight': true,
};
const commonHandlers = hover
? {
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(childValue);
element.props.onMouseEnter?.(e);
},
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(null);
element.props.onMouseLeave?.(e);
},
}
: click
? {
onClick: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(childValue);
element.props.onClick?.(e);
},
}
: {};
if (asChild) {
if (mode === 'children') {
return React.cloneElement(
element,
{
key: childValue,
ref: refCallback,
className: cn('relative', element.props.className),
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item-container',
}),
...commonHandlers,
...props,
},
<>
<AnimatePresence initial={false} mode="wait">
{isActive && !isDisabled && (
<motion.div
layoutId={`transition-background-${contextId}`}
data-slot="motion-highlight"
style={{
position: 'absolute',
zIndex: 0,
...contextStyle,
...style,
}}
className={cn(contextClassName, activeClassName)}
transition={itemTransition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: {
...itemTransition,
delay:
(itemTransition?.delay ?? 0) +
(exitDelay ?? contextExitDelay ?? 0) / 1000,
},
}}
{...dataAttributes}
/>
)}
</AnimatePresence>
<Component
data-slot="motion-highlight-item"
style={{ position: 'relative', zIndex: 1 }}
className={className}
{...dataAttributes}
>
{children}
</Component>
</>,
);
}
return React.cloneElement(element, {
ref: refCallback,
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item',
}),
...commonHandlers,
});
}
return enabled ? (
<Component
key={childValue}
ref={localRef}
data-slot="motion-highlight-item-container"
className={cn(mode === 'children' && 'relative', className)}
{...dataAttributes}
{...props}
{...commonHandlers}
>
{mode === 'children' && (
<AnimatePresence initial={false} mode="wait">
{isActive && !isDisabled && (
<motion.div
layoutId={`transition-background-${contextId}`}
data-slot="motion-highlight"
style={{
position: 'absolute',
zIndex: 0,
...contextStyle,
...style,
}}
className={cn(contextClassName, activeClassName)}
transition={itemTransition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: {
...itemTransition,
delay:
(itemTransition?.delay ?? 0) +
(exitDelay ?? contextExitDelay ?? 0) / 1000,
},
}}
{...dataAttributes}
/>
)}
</AnimatePresence>
)}
{React.cloneElement(element, {
style: { position: 'relative', zIndex: 1 },
className: element.props.className,
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item',
}),
})}
</Component>
) : (
children
);
}
export {
Highlight,
HighlightItem,
useHighlight,
type HighlightProps,
type HighlightItemProps,
};
@@ -0,0 +1,207 @@
'use client';
import * as React from 'react';
import { Dialog as DialogPrimitive } from 'radix-ui';
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import { useControlledState } from '@/hooks/use-controlled-state';
import { getStrictContext } from '@/lib/get-strict-context';
type DialogContextType = {
isOpen: boolean;
setIsOpen: DialogProps['onOpenChange'];
};
const [DialogProvider, useDialog] =
getStrictContext<DialogContextType>('DialogContext');
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>;
function Dialog(props: DialogProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<DialogProvider value={{ isOpen, setIsOpen }}>
<DialogPrimitive.Root
data-slot="dialog"
{...props}
onOpenChange={setIsOpen}
/>
</DialogProvider>
);
}
type DialogTriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>;
function DialogTrigger(props: DialogTriggerProps) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
type DialogPortalProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Portal>,
'forceMount'
>;
function DialogPortal(props: DialogPortalProps) {
const { isOpen } = useDialog();
return (
<AnimatePresence>
{isOpen && (
<DialogPrimitive.Portal
data-slot="dialog-portal"
forceMount
{...props}
/>
)}
</AnimatePresence>
);
}
type DialogOverlayProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Overlay>,
'forceMount' | 'asChild'
> &
HTMLMotionProps<'div'>;
function DialogOverlay({
transition = { duration: 0.2, ease: 'easeInOut' },
...props
}: DialogOverlayProps) {
return (
<DialogPrimitive.Overlay data-slot="dialog-overlay" asChild forceMount>
<motion.div
key="dialog-overlay"
initial={{ opacity: 0, filter: 'blur(4px)' }}
animate={{ opacity: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, filter: 'blur(4px)' }}
transition={transition}
{...props}
/>
</DialogPrimitive.Overlay>
);
}
type DialogFlipDirection = 'top' | 'bottom' | 'left' | 'right';
type DialogContentProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Content>,
'forceMount' | 'asChild'
> &
HTMLMotionProps<'div'> & {
from?: DialogFlipDirection;
};
function DialogContent({
from = 'top',
onOpenAutoFocus,
onCloseAutoFocus,
onEscapeKeyDown,
onPointerDownOutside,
onInteractOutside,
transition = { type: 'spring', stiffness: 150, damping: 25 },
...props
}: DialogContentProps) {
const initialRotation =
from === 'bottom' || from === 'left' ? '20deg' : '-20deg';
const isVertical = from === 'top' || from === 'bottom';
const rotateAxis = isVertical ? 'rotateX' : 'rotateY';
return (
<DialogPrimitive.Content
asChild
forceMount
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
onInteractOutside={onInteractOutside}
>
<motion.div
key="dialog-content"
data-slot="dialog-content"
initial={{
opacity: 0,
filter: 'blur(4px)',
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
animate={{
opacity: 1,
filter: 'blur(0px)',
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
}}
exit={{
opacity: 0,
filter: 'blur(4px)',
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
transition={transition}
{...props}
/>
</DialogPrimitive.Content>
);
}
type DialogCloseProps = React.ComponentProps<typeof DialogPrimitive.Close>;
function DialogClose(props: DialogCloseProps) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
type DialogHeaderProps = React.ComponentProps<'div'>;
function DialogHeader(props: DialogHeaderProps) {
return <div data-slot="dialog-header" {...props} />;
}
type DialogFooterProps = React.ComponentProps<'div'>;
function DialogFooter(props: DialogFooterProps) {
return <div data-slot="dialog-footer" {...props} />;
}
type DialogTitleProps = React.ComponentProps<typeof DialogPrimitive.Title>;
function DialogTitle(props: DialogTitleProps) {
return <DialogPrimitive.Title data-slot="dialog-title" {...props} />;
}
type DialogDescriptionProps = React.ComponentProps<
typeof DialogPrimitive.Description
>;
function DialogDescription(props: DialogDescriptionProps) {
return (
<DialogPrimitive.Description data-slot="dialog-description" {...props} />
);
}
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
useDialog,
type DialogProps,
type DialogTriggerProps,
type DialogPortalProps,
type DialogCloseProps,
type DialogOverlayProps,
type DialogContentProps,
type DialogHeaderProps,
type DialogFooterProps,
type DialogTitleProps,
type DialogDescriptionProps,
type DialogContextType,
type DialogFlipDirection,
};
@@ -0,0 +1,563 @@
'use client';
import * as React from 'react';
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import {
Highlight,
HighlightItem,
type HighlightItemProps,
type HighlightProps,
} from '@/components/animate-ui/primitives/effects/highlight';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
import { useDataState } from '@/hooks/use-data-state';
type DropdownMenuContextType = {
isOpen: boolean;
setIsOpen: (o: boolean) => void;
highlightedValue: string | null;
setHighlightedValue: (value: string | null) => void;
};
type DropdownMenuSubContextType = {
isOpen: boolean;
setIsOpen: (o: boolean) => void;
};
const [DropdownMenuProvider, useDropdownMenu] =
getStrictContext<DropdownMenuContextType>('DropdownMenuContext');
const [DropdownMenuSubProvider, useDropdownMenuSub] =
getStrictContext<DropdownMenuSubContextType>('DropdownMenuSubContext');
type DropdownMenuProps = React.ComponentProps<
typeof DropdownMenuPrimitive.Root
>;
function DropdownMenu(props: DropdownMenuProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
const [highlightedValue, setHighlightedValue] = React.useState<string | null>(
null,
);
return (
<DropdownMenuProvider
value={{ isOpen, setIsOpen, highlightedValue, setHighlightedValue }}
>
<DropdownMenuPrimitive.Root
data-slot="dropdown-menu"
{...props}
onOpenChange={setIsOpen}
/>
</DropdownMenuProvider>
);
}
type DropdownMenuTriggerProps = React.ComponentProps<
typeof DropdownMenuPrimitive.Trigger
>;
function DropdownMenuTrigger(props: DropdownMenuTriggerProps) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
type DropdownMenuPortalProps = React.ComponentProps<
typeof DropdownMenuPrimitive.Portal
>;
function DropdownMenuPortal(props: DropdownMenuPortalProps) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
type DropdownMenuGroupProps = React.ComponentProps<
typeof DropdownMenuPrimitive.Group
>;
function DropdownMenuGroup(props: DropdownMenuGroupProps) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
type DropdownMenuSubProps = React.ComponentProps<
typeof DropdownMenuPrimitive.Sub
>;
function DropdownMenuSub(props: DropdownMenuSubProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<DropdownMenuSubProvider value={{ isOpen, setIsOpen }}>
<DropdownMenuPrimitive.Sub
data-slot="dropdown-menu-sub"
{...props}
onOpenChange={setIsOpen}
/>
</DropdownMenuSubProvider>
);
}
type DropdownMenuRadioGroupProps = React.ComponentProps<
typeof DropdownMenuPrimitive.RadioGroup
>;
function DropdownMenuRadioGroup(props: DropdownMenuRadioGroupProps) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
type DropdownMenuSubTriggerProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger>,
'asChild'
> &
HTMLMotionProps<'div'>;
function DropdownMenuSubTrigger({
disabled,
textValue,
...props
}: DropdownMenuSubTriggerProps) {
const { setHighlightedValue } = useDropdownMenu();
const [, highlightedRef] = useDataState<HTMLDivElement>(
'highlighted',
undefined,
(value) => {
if (value === true) {
const el = highlightedRef.current;
const v = el?.dataset.value || el?.id || null;
if (v) setHighlightedValue(v);
}
},
);
return (
<DropdownMenuPrimitive.SubTrigger
ref={highlightedRef}
disabled={disabled}
textValue={textValue}
asChild
>
<motion.div
data-slot="dropdown-menu-sub-trigger"
data-disabled={disabled}
{...props}
/>
</DropdownMenuPrimitive.SubTrigger>
);
}
type DropdownMenuSubContentProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>,
'forceMount' | 'asChild'
> &
Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.Portal>,
'forceMount'
> &
HTMLMotionProps<'div'>;
function DropdownMenuSubContent({
loop,
onEscapeKeyDown,
onPointerDownOutside,
onFocusOutside,
onInteractOutside,
sideOffset,
alignOffset,
avoidCollisions,
collisionBoundary,
collisionPadding,
arrowPadding,
sticky,
hideWhenDetached,
transition = { duration: 0.2 },
style,
container,
...props
}: DropdownMenuSubContentProps) {
const { isOpen } = useDropdownMenuSub();
return (
<AnimatePresence>
{isOpen && (
<DropdownMenuPortal forceMount container={container}>
<DropdownMenuPrimitive.SubContent
asChild
forceMount
loop={loop}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
onFocusOutside={onFocusOutside}
onInteractOutside={onInteractOutside}
sideOffset={sideOffset}
alignOffset={alignOffset}
avoidCollisions={avoidCollisions}
collisionBoundary={collisionBoundary}
collisionPadding={collisionPadding}
arrowPadding={arrowPadding}
sticky={sticky}
hideWhenDetached={hideWhenDetached}
>
<motion.div
key="dropdown-menu-sub-content"
data-slot="dropdown-menu-sub-content"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={transition}
style={{ willChange: 'opacity, transform', ...style }}
{...props}
/>
</DropdownMenuPrimitive.SubContent>
</DropdownMenuPortal>
)}
</AnimatePresence>
);
}
type DropdownMenuHighlightProps = Omit<
HighlightProps,
'controlledItems' | 'enabled' | 'hover'
> & {
animateOnHover?: boolean;
};
function DropdownMenuHighlight({
transition = { type: 'spring', stiffness: 350, damping: 35 },
...props
}: DropdownMenuHighlightProps) {
const { highlightedValue } = useDropdownMenu();
return (
<Highlight
data-slot="dropdown-menu-highlight"
click={false}
controlledItems
transition={transition}
value={highlightedValue}
{...props}
/>
);
}
type DropdownMenuContentProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.Content>,
'forceMount' | 'asChild'
> &
Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.Portal>,
'forceMount'
> &
HTMLMotionProps<'div'>;
function DropdownMenuContent({
loop,
onCloseAutoFocus,
onEscapeKeyDown,
onPointerDownOutside,
onFocusOutside,
onInteractOutside,
side,
sideOffset,
align,
alignOffset,
avoidCollisions,
collisionBoundary,
collisionPadding,
arrowPadding,
sticky,
hideWhenDetached,
transition = { duration: 0.2 },
style,
container,
...props
}: DropdownMenuContentProps) {
const { isOpen } = useDropdownMenu();
return (
<AnimatePresence>
{isOpen && (
<DropdownMenuPortal forceMount container={container}>
<DropdownMenuPrimitive.Content
asChild
loop={loop}
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
onFocusOutside={onFocusOutside}
onInteractOutside={onInteractOutside}
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
avoidCollisions={avoidCollisions}
collisionBoundary={collisionBoundary}
collisionPadding={collisionPadding}
arrowPadding={arrowPadding}
sticky={sticky}
hideWhenDetached={hideWhenDetached}
>
<motion.div
key="dropdown-menu-content"
data-slot="dropdown-menu-content"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={transition}
style={{ willChange: 'opacity, transform', ...style }}
{...props}
/>
</DropdownMenuPrimitive.Content>
</DropdownMenuPortal>
)}
</AnimatePresence>
);
}
type DropdownMenuHighlightItemProps = HighlightItemProps;
function DropdownMenuHighlightItem(props: DropdownMenuHighlightItemProps) {
return <HighlightItem data-slot="dropdown-menu-highlight-item" {...props} />;
}
type DropdownMenuItemProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.Item>,
'asChild'
> &
HTMLMotionProps<'div'>;
function DropdownMenuItem({
disabled,
onSelect,
textValue,
...props
}: DropdownMenuItemProps) {
const { setHighlightedValue } = useDropdownMenu();
const [, highlightedRef] = useDataState<HTMLDivElement>(
'highlighted',
undefined,
(value) => {
if (value === true) {
const el = highlightedRef.current;
const v = el?.dataset.value || el?.id || null;
if (v) setHighlightedValue(v);
}
},
);
return (
<DropdownMenuPrimitive.Item
ref={highlightedRef}
disabled={disabled}
onSelect={onSelect}
textValue={textValue}
asChild
>
<motion.div
data-slot="dropdown-menu-item"
data-disabled={disabled}
{...props}
/>
</DropdownMenuPrimitive.Item>
);
}
type DropdownMenuCheckboxItemProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>,
'asChild'
> &
HTMLMotionProps<'div'>;
function DropdownMenuCheckboxItem({
checked,
onCheckedChange,
disabled,
onSelect,
textValue,
...props
}: DropdownMenuCheckboxItemProps) {
const { setHighlightedValue } = useDropdownMenu();
const [, highlightedRef] = useDataState<HTMLDivElement>(
'highlighted',
undefined,
(value) => {
if (value === true) {
const el = highlightedRef.current;
const v = el?.dataset.value || el?.id || null;
if (v) setHighlightedValue(v);
}
},
);
return (
<DropdownMenuPrimitive.CheckboxItem
ref={highlightedRef}
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
onSelect={onSelect}
textValue={textValue}
asChild
>
<motion.div
data-slot="dropdown-menu-checkbox-item"
data-disabled={disabled}
{...props}
/>
</DropdownMenuPrimitive.CheckboxItem>
);
}
type DropdownMenuRadioItemProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>,
'asChild'
> &
HTMLMotionProps<'div'>;
function DropdownMenuRadioItem({
value,
disabled,
onSelect,
textValue,
...props
}: DropdownMenuRadioItemProps) {
const { setHighlightedValue } = useDropdownMenu();
const [, highlightedRef] = useDataState<HTMLDivElement>(
'highlighted',
undefined,
(value) => {
if (value === true) {
const el = highlightedRef.current;
const v = el?.dataset.value || el?.id || null;
if (v) setHighlightedValue(v);
}
},
);
return (
<DropdownMenuPrimitive.RadioItem
ref={highlightedRef}
value={value}
disabled={disabled}
onSelect={onSelect}
textValue={textValue}
asChild
>
<motion.div
data-slot="dropdown-menu-radio-item"
data-disabled={disabled}
{...props}
/>
</DropdownMenuPrimitive.RadioItem>
);
}
type DropdownMenuLabelProps = React.ComponentProps<
typeof DropdownMenuPrimitive.Label
>;
function DropdownMenuLabel(props: DropdownMenuLabelProps) {
return (
<DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" {...props} />
);
}
type DropdownMenuSeparatorProps = React.ComponentProps<
typeof DropdownMenuPrimitive.Separator
>;
function DropdownMenuSeparator(props: DropdownMenuSeparatorProps) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
{...props}
/>
);
}
type DropdownMenuShortcutProps = React.ComponentProps<'span'>;
function DropdownMenuShortcut(props: DropdownMenuShortcutProps) {
return <span data-slot="dropdown-menu-shortcut" {...props} />;
}
type DropdownMenuItemIndicatorProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.ItemIndicator>,
'asChild'
> &
HTMLMotionProps<'div'>;
function DropdownMenuItemIndicator(props: DropdownMenuItemIndicatorProps) {
return (
<DropdownMenuPrimitive.ItemIndicator
data-slot="dropdown-menu-item-indicator"
asChild
>
<motion.div {...props} />
</DropdownMenuPrimitive.ItemIndicator>
);
}
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuHighlight,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemIndicator,
DropdownMenuHighlightItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
useDropdownMenu,
useDropdownMenuSub,
type DropdownMenuProps,
type DropdownMenuTriggerProps,
type DropdownMenuHighlightProps,
type DropdownMenuContentProps,
type DropdownMenuItemProps,
type DropdownMenuItemIndicatorProps,
type DropdownMenuHighlightItemProps,
type DropdownMenuCheckboxItemProps,
type DropdownMenuRadioItemProps,
type DropdownMenuLabelProps,
type DropdownMenuSeparatorProps,
type DropdownMenuShortcutProps,
type DropdownMenuGroupProps,
type DropdownMenuPortalProps,
type DropdownMenuSubProps,
type DropdownMenuSubContentProps,
type DropdownMenuSubTriggerProps,
type DropdownMenuRadioGroupProps,
type DropdownMenuContextType,
type DropdownMenuSubContextType,
};
@@ -0,0 +1,207 @@
'use client';
import * as React from 'react';
import { HoverCard as HoverCardPrimitive } from 'radix-ui';
import {
AnimatePresence,
motion,
useMotionValue,
useSpring,
type MotionValue,
type HTMLMotionProps,
type SpringOptions,
} from 'motion/react';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
type HoverCardContextType = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
x: MotionValue<number>;
y: MotionValue<number>;
followCursor?: boolean | 'x' | 'y';
followCursorSpringOptions?: SpringOptions;
};
const [HoverCardProvider, useHoverCard] =
getStrictContext<HoverCardContextType>('HoverCardContext');
type HoverCardProps = React.ComponentProps<typeof HoverCardPrimitive.Root> & {
followCursor?: boolean | 'x' | 'y';
followCursorSpringOptions?: SpringOptions;
};
function HoverCard({
followCursor = false,
followCursorSpringOptions = { stiffness: 200, damping: 17 },
...props
}: HoverCardProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
const x = useMotionValue(0);
const y = useMotionValue(0);
return (
<HoverCardProvider
value={{
isOpen,
setIsOpen,
x,
y,
followCursor,
followCursorSpringOptions,
}}
>
<HoverCardPrimitive.Root
data-slot="hover-card"
{...props}
onOpenChange={setIsOpen}
/>
</HoverCardProvider>
);
}
type HoverCardTriggerProps = React.ComponentProps<
typeof HoverCardPrimitive.Trigger
>;
function HoverCardTrigger({ onMouseMove, ...props }: HoverCardTriggerProps) {
const { x, y, followCursor } = useHoverCard();
const handleMouseMove = (event: React.MouseEvent<HTMLAnchorElement>) => {
onMouseMove?.(event);
const target = event.currentTarget.getBoundingClientRect();
if (followCursor === 'x' || followCursor === true) {
const eventOffsetX = event.clientX - target.left;
const offsetXFromCenter = (eventOffsetX - target.width / 2) / 2;
x.set(offsetXFromCenter);
}
if (followCursor === 'y' || followCursor === true) {
const eventOffsetY = event.clientY - target.top;
const offsetYFromCenter = (eventOffsetY - target.height / 2) / 2;
y.set(offsetYFromCenter);
}
};
return (
<HoverCardPrimitive.Trigger
data-slot="hover-card-trigger"
onMouseMove={handleMouseMove}
{...props}
/>
);
}
type HoverCardPortalProps = Omit<
React.ComponentProps<typeof HoverCardPrimitive.Portal>,
'forceMount'
>;
function HoverCardPortal(props: HoverCardPortalProps) {
const { isOpen } = useHoverCard();
return (
<AnimatePresence>
{isOpen && (
<HoverCardPrimitive.Portal
forceMount
data-slot="hover-card-portal"
{...props}
/>
)}
</AnimatePresence>
);
}
type HoverCardContentProps = React.ComponentProps<
typeof HoverCardPrimitive.Content
> &
HTMLMotionProps<'div'>;
function HoverCardContent({
align,
alignOffset,
side,
sideOffset,
avoidCollisions,
collisionBoundary,
collisionPadding,
arrowPadding,
sticky,
hideWhenDetached,
style,
transition = { type: 'spring', stiffness: 300, damping: 25 },
...props
}: HoverCardContentProps) {
const { x, y, followCursor, followCursorSpringOptions } = useHoverCard();
const translateX = useSpring(x, followCursorSpringOptions);
const translateY = useSpring(y, followCursorSpringOptions);
return (
<HoverCardPrimitive.Content
asChild
forceMount
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
avoidCollisions={avoidCollisions}
collisionBoundary={collisionBoundary}
collisionPadding={collisionPadding}
arrowPadding={arrowPadding}
sticky={sticky}
hideWhenDetached={hideWhenDetached}
>
<motion.div
key="hover-card-content"
data-slot="hover-card-content"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={transition}
style={{
x:
followCursor === 'x' || followCursor === true
? translateX
: undefined,
y:
followCursor === 'y' || followCursor === true
? translateY
: undefined,
...style,
}}
{...props}
/>
</HoverCardPrimitive.Content>
);
}
type HoverCardArrowProps = React.ComponentProps<
typeof HoverCardPrimitive.Arrow
>;
function HoverCardArrow(props: HoverCardArrowProps) {
return <HoverCardPrimitive.Arrow data-slot="hover-card-arrow" {...props} />;
}
export {
HoverCard,
HoverCardTrigger,
HoverCardPortal,
HoverCardContent,
HoverCardArrow,
useHoverCard,
type HoverCardProps,
type HoverCardTriggerProps,
type HoverCardPortalProps,
type HoverCardContentProps,
type HoverCardArrowProps,
type HoverCardContextType,
};
@@ -0,0 +1,162 @@
'use client';
import * as React from 'react';
import { Popover as PopoverPrimitive } from 'radix-ui';
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
type PopoverContextType = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
};
const [PopoverProvider, usePopover] =
getStrictContext<PopoverContextType>('PopoverContext');
type PopoverProps = React.ComponentProps<typeof PopoverPrimitive.Root>;
function Popover(props: PopoverProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<PopoverProvider value={{ isOpen, setIsOpen }}>
<PopoverPrimitive.Root
data-slot="popover"
{...props}
onOpenChange={setIsOpen}
/>
</PopoverProvider>
);
}
type PopoverTriggerProps = React.ComponentProps<
typeof PopoverPrimitive.Trigger
>;
function PopoverTrigger(props: PopoverTriggerProps) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
type PopoverPortalProps = Omit<
React.ComponentProps<typeof PopoverPrimitive.Portal>,
'forceMount'
>;
function PopoverPortal(props: PopoverPortalProps) {
const { isOpen } = usePopover();
return (
<AnimatePresence>
{isOpen && (
<PopoverPrimitive.Portal
forceMount
data-slot="popover-portal"
{...props}
/>
)}
</AnimatePresence>
);
}
type PopoverContentProps = Omit<
React.ComponentProps<typeof PopoverPrimitive.Content>,
'forceMount' | 'asChild'
> &
HTMLMotionProps<'div'>;
function PopoverContent({
onOpenAutoFocus,
onCloseAutoFocus,
onEscapeKeyDown,
onPointerDownOutside,
onFocusOutside,
onInteractOutside,
align,
alignOffset,
side,
sideOffset,
avoidCollisions,
collisionBoundary,
collisionPadding,
arrowPadding,
sticky,
hideWhenDetached,
transition = { type: 'spring', stiffness: 300, damping: 25 },
...props
}: PopoverContentProps) {
return (
<PopoverPrimitive.Content
asChild
forceMount
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
avoidCollisions={avoidCollisions}
collisionBoundary={collisionBoundary}
collisionPadding={collisionPadding}
arrowPadding={arrowPadding}
sticky={sticky}
hideWhenDetached={hideWhenDetached}
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
onInteractOutside={onInteractOutside}
onFocusOutside={onFocusOutside}
>
<motion.div
key="popover-content"
data-slot="popover-content"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={transition}
{...props}
/>
</PopoverPrimitive.Content>
);
}
type PopoverAnchorProps = React.ComponentProps<typeof PopoverPrimitive.Anchor>;
function PopoverAnchor({ ...props }: PopoverAnchorProps) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
type PopoverArrowProps = React.ComponentProps<typeof PopoverPrimitive.Arrow>;
function PopoverArrow(props: PopoverArrowProps) {
return <PopoverPrimitive.Arrow data-slot="popover-arrow" {...props} />;
}
type PopoverCloseProps = React.ComponentProps<typeof PopoverPrimitive.Close>;
function PopoverClose(props: PopoverCloseProps) {
return <PopoverPrimitive.Close data-slot="popover-close" {...props} />;
}
export {
Popover,
PopoverTrigger,
PopoverPortal,
PopoverContent,
PopoverAnchor,
PopoverClose,
PopoverArrow,
usePopover,
type PopoverProps,
type PopoverTriggerProps,
type PopoverPortalProps,
type PopoverContentProps,
type PopoverAnchorProps,
type PopoverCloseProps,
type PopoverArrowProps,
type PopoverContextType,
};
@@ -0,0 +1,191 @@
'use client';
import * as React from 'react';
import { Dialog as SheetPrimitive } from 'radix-ui';
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
type SheetContextType = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
};
const [SheetProvider, useSheet] =
getStrictContext<SheetContextType>('SheetContext');
type SheetProps = React.ComponentProps<typeof SheetPrimitive.Root>;
function Sheet(props: SheetProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props.open,
defaultValue: props.defaultOpen,
onChange: props.onOpenChange,
});
return (
<SheetProvider value={{ isOpen, setIsOpen }}>
<SheetPrimitive.Root
data-slot="sheet"
{...props}
onOpenChange={setIsOpen}
/>
</SheetProvider>
);
}
type SheetTriggerProps = React.ComponentProps<typeof SheetPrimitive.Trigger>;
function SheetTrigger(props: SheetTriggerProps) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
type SheetCloseProps = React.ComponentProps<typeof SheetPrimitive.Close>;
function SheetClose(props: SheetCloseProps) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
type SheetPortalProps = React.ComponentProps<typeof SheetPrimitive.Portal>;
function SheetPortal(props: SheetPortalProps) {
const { isOpen } = useSheet();
return (
<AnimatePresence>
{isOpen && (
<SheetPrimitive.Portal forceMount data-slot="sheet-portal" {...props} />
)}
</AnimatePresence>
);
}
type SheetOverlayProps = Omit<
React.ComponentProps<typeof SheetPrimitive.Overlay>,
'asChild' | 'forceMount'
> &
HTMLMotionProps<'div'>;
function SheetOverlay({
transition = { duration: 0.2, ease: 'easeInOut' },
...props
}: SheetOverlayProps) {
return (
<SheetPrimitive.Overlay asChild forceMount>
<motion.div
key="sheet-overlay"
data-slot="sheet-overlay"
initial={{ opacity: 0, filter: 'blur(4px)' }}
animate={{ opacity: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, filter: 'blur(4px)' }}
transition={transition}
{...props}
/>
</SheetPrimitive.Overlay>
);
}
type Side = 'top' | 'bottom' | 'left' | 'right';
type SheetContentProps = React.ComponentProps<typeof SheetPrimitive.Content> &
HTMLMotionProps<'div'> & {
side?: Side;
};
function SheetContent({
side = 'right',
transition = { type: 'spring', stiffness: 150, damping: 22 },
style,
children,
...props
}: SheetContentProps) {
const axis = side === 'left' || side === 'right' ? 'x' : 'y';
const offscreen: Record<Side, { x?: string; y?: string; opacity: number }> = {
right: { x: '100%', opacity: 0 },
left: { x: '-100%', opacity: 0 },
top: { y: '-100%', opacity: 0 },
bottom: { y: '100%', opacity: 0 },
};
const positionStyle: Record<Side, React.CSSProperties> = {
right: { insetBlock: 0, right: 0 },
left: { insetBlock: 0, left: 0 },
top: { insetInline: 0, top: 0 },
bottom: { insetInline: 0, bottom: 0 },
};
return (
<SheetPrimitive.Content asChild forceMount {...props}>
<motion.div
key="sheet-content"
data-slot="sheet-content"
data-side={side}
initial={offscreen[side]}
animate={{ [axis]: 0, opacity: 1 }}
exit={offscreen[side]}
style={{
position: 'fixed',
...positionStyle[side],
...style,
}}
transition={transition}
>
{children}
</motion.div>
</SheetPrimitive.Content>
);
}
type SheetHeaderProps = React.ComponentProps<'div'>;
function SheetHeader(props: SheetHeaderProps) {
return <div data-slot="sheet-header" {...props} />;
}
type SheetFooterProps = React.ComponentProps<'div'>;
function SheetFooter(props: SheetFooterProps) {
return <div data-slot="sheet-footer" {...props} />;
}
type SheetTitleProps = React.ComponentProps<typeof SheetPrimitive.Title>;
function SheetTitle(props: SheetTitleProps) {
return <SheetPrimitive.Title data-slot="sheet-title" {...props} />;
}
type SheetDescriptionProps = React.ComponentProps<
typeof SheetPrimitive.Description
>;
function SheetDescription(props: SheetDescriptionProps) {
return (
<SheetPrimitive.Description data-slot="sheet-description" {...props} />
);
}
export {
useSheet,
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
type SheetProps,
type SheetPortalProps,
type SheetOverlayProps,
type SheetTriggerProps,
type SheetCloseProps,
type SheetContentProps,
type SheetHeaderProps,
type SheetFooterProps,
type SheetTitleProps,
type SheetDescriptionProps,
};
@@ -0,0 +1,155 @@
'use client';
import * as React from 'react';
import { Switch as SwitchPrimitives } from 'radix-ui';
import {
motion,
type TargetAndTransition,
type VariantLabels,
type HTMLMotionProps,
type LegacyAnimationControls,
} from 'motion/react';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
type SwitchContextType = {
isChecked: boolean;
setIsChecked: (isChecked: boolean) => void;
isPressed: boolean;
setIsPressed: (isPressed: boolean) => void;
};
const [SwitchProvider, useSwitch] =
getStrictContext<SwitchContextType>('SwitchContext');
type SwitchProps = Omit<
React.ComponentProps<typeof SwitchPrimitives.Root>,
'asChild'
> &
HTMLMotionProps<'button'>;
function Switch(props: SwitchProps) {
// Destructure Radix-only props so they don't leak onto the motion.button DOM element
const {
checked,
defaultChecked,
onCheckedChange,
disabled,
required,
name,
value,
form,
...motionProps
} = props;
const [isPressed, setIsPressed] = React.useState(false);
const [isChecked, setIsChecked] = useControlledState({
value: checked,
defaultValue: defaultChecked,
onChange: onCheckedChange,
});
return (
<SwitchProvider
value={{ isChecked, setIsChecked, isPressed, setIsPressed }}
>
<SwitchPrimitives.Root
checked={checked}
defaultChecked={defaultChecked}
onCheckedChange={setIsChecked}
disabled={disabled}
required={required}
name={name}
value={value}
form={form}
asChild
>
<motion.button
data-slot="switch"
whileTap="tap"
initial={false}
onTapStart={() => setIsPressed(true)}
onTapCancel={() => setIsPressed(false)}
onTap={() => setIsPressed(false)}
{...motionProps}
/>
</SwitchPrimitives.Root>
</SwitchProvider>
);
}
type SwitchThumbProps = Omit<
React.ComponentProps<typeof SwitchPrimitives.Thumb>,
'asChild'
> &
HTMLMotionProps<'div'> & {
pressedAnimation?:
| TargetAndTransition
| VariantLabels
| boolean
| LegacyAnimationControls;
};
function SwitchThumb({
pressedAnimation,
transition = { type: 'spring', stiffness: 300, damping: 25 },
...props
}: SwitchThumbProps) {
const { isPressed } = useSwitch();
return (
<SwitchPrimitives.Thumb asChild>
<motion.div
data-slot="switch-thumb"
whileTap="tab"
layout
transition={transition}
animate={isPressed ? pressedAnimation : undefined}
{...props}
/>
</SwitchPrimitives.Thumb>
);
}
type SwitchIconPosition = 'left' | 'right' | 'thumb';
type SwitchIconProps = HTMLMotionProps<'div'> & {
position: SwitchIconPosition;
};
function SwitchIcon({
position,
transition = { type: 'spring', bounce: 0 },
...props
}: SwitchIconProps) {
const { isChecked } = useSwitch();
const isAnimated = React.useMemo(() => {
if (position === 'right') return !isChecked;
if (position === 'left') return isChecked;
if (position === 'thumb') return true;
return false;
}, [position, isChecked]);
return (
<motion.div
data-slot={`switch-${position}-icon`}
animate={isAnimated ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}
transition={transition}
{...props}
/>
);
}
export {
Switch,
SwitchThumb,
SwitchIcon,
useSwitch,
type SwitchProps,
type SwitchThumbProps,
type SwitchIconProps,
type SwitchIconPosition,
type SwitchContextType,
};
@@ -0,0 +1,189 @@
'use client';
import * as React from 'react';
import { Tabs as TabsPrimitive } from 'radix-ui';
import {
motion,
AnimatePresence,
type HTMLMotionProps,
type Transition,
} from 'motion/react';
import {
Highlight,
HighlightItem,
type HighlightProps,
type HighlightItemProps,
} from '@/components/animate-ui/primitives/effects/highlight';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
import {
AutoHeight,
type AutoHeightProps,
} from '@/components/animate-ui/primitives/effects/auto-height';
type TabsContextType = {
value: string | undefined;
setValue: TabsProps['onValueChange'];
};
const [TabsProvider, useTabs] =
getStrictContext<TabsContextType>('TabsContext');
type TabsProps = React.ComponentProps<typeof TabsPrimitive.Root>;
function Tabs(props: TabsProps) {
const [value, setValue] = useControlledState({
value: props.value,
defaultValue: props.defaultValue,
onChange: props.onValueChange,
});
return (
<TabsProvider value={{ value, setValue }}>
<TabsPrimitive.Root
data-slot="tabs"
{...props}
onValueChange={setValue}
/>
</TabsProvider>
);
}
type TabsHighlightProps = Omit<HighlightProps, 'controlledItems' | 'value'>;
function TabsHighlight({
transition = { type: 'spring', stiffness: 200, damping: 25 },
...props
}: TabsHighlightProps) {
const { value } = useTabs();
return (
<Highlight
data-slot="tabs-highlight"
controlledItems
value={value}
transition={transition}
click={false}
{...props}
/>
);
}
type TabsListProps = React.ComponentProps<typeof TabsPrimitive.List>;
function TabsList(props: TabsListProps) {
return <TabsPrimitive.List data-slot="tabs-list" {...props} />;
}
type TabsHighlightItemProps = HighlightItemProps & {
value: string;
};
function TabsHighlightItem(props: TabsHighlightItemProps) {
return <HighlightItem data-slot="tabs-highlight-item" {...props} />;
}
type TabsTriggerProps = React.ComponentProps<typeof TabsPrimitive.Trigger>;
function TabsTrigger(props: TabsTriggerProps) {
return <TabsPrimitive.Trigger data-slot="tabs-trigger" {...props} />;
}
type TabsContentProps = React.ComponentProps<typeof TabsPrimitive.Content> &
HTMLMotionProps<'div'>;
function TabsContent({
value,
forceMount,
transition = { duration: 0.5, ease: 'easeInOut' },
...props
}: TabsContentProps) {
return (
<AnimatePresence mode="wait">
<TabsPrimitive.Content asChild forceMount={forceMount} value={value}>
<motion.div
data-slot="tabs-content"
layout
layoutDependency={value}
initial={{ opacity: 0, filter: 'blur(4px)' }}
animate={{ opacity: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, filter: 'blur(4px)' }}
transition={transition}
{...props}
/>
</TabsPrimitive.Content>
</AnimatePresence>
);
}
type TabsContentsAutoProps = AutoHeightProps & {
mode?: 'auto-height';
children: React.ReactNode;
transition?: Transition;
};
type TabsContentsLayoutProps = Omit<HTMLMotionProps<'div'>, 'transition'> & {
mode: 'layout';
children: React.ReactNode;
transition?: Transition;
};
type TabsContentsProps = TabsContentsAutoProps | TabsContentsLayoutProps;
const defaultTransition: Transition = {
type: 'spring',
stiffness: 200,
damping: 30,
};
function isAutoMode(props: TabsContentsProps): props is TabsContentsAutoProps {
return !('mode' in props) || props.mode === 'auto-height';
}
function TabsContents(props: TabsContentsProps) {
const { value } = useTabs();
if (isAutoMode(props)) {
const { transition = defaultTransition, ...autoProps } = props;
return (
<AutoHeight
data-slot="tabs-contents"
deps={[value]}
transition={transition}
{...autoProps}
/>
);
}
const { transition = defaultTransition, style, ...layoutProps } = props;
return (
<motion.div
data-slot="tabs-contents"
layout="size"
layoutDependency={value}
style={{ overflow: 'hidden', ...style }}
transition={{ layout: transition }}
{...layoutProps}
/>
);
}
export {
Tabs,
TabsHighlight,
TabsHighlightItem,
TabsList,
TabsTrigger,
TabsContent,
TabsContents,
type TabsProps,
type TabsHighlightProps,
type TabsHighlightItemProps,
type TabsListProps,
type TabsTriggerProps,
type TabsContentProps,
type TabsContentsProps,
};
@@ -0,0 +1,220 @@
'use client';
import * as React from 'react';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import {
AnimatePresence,
motion,
useMotionValue,
useSpring,
type SpringOptions,
type HTMLMotionProps,
type MotionValue,
} from 'motion/react';
import { getStrictContext } from '@/lib/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
type TooltipContextType = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
x: MotionValue<number>;
y: MotionValue<number>;
followCursor?: boolean | 'x' | 'y';
followCursorSpringOptions?: SpringOptions;
};
const [LocalTooltipProvider, useTooltip] =
getStrictContext<TooltipContextType>('TooltipContext');
type TooltipProviderProps = React.ComponentProps<
typeof TooltipPrimitive.Provider
>;
function TooltipProvider(props: TooltipProviderProps) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" {...props} />;
}
type TooltipProps = React.ComponentProps<typeof TooltipPrimitive.Root> & {
followCursor?: boolean | 'x' | 'y';
followCursorSpringOptions?: SpringOptions;
};
function Tooltip({
followCursor = false,
followCursorSpringOptions = { stiffness: 200, damping: 17 },
...props
}: TooltipProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
const x = useMotionValue(0);
const y = useMotionValue(0);
return (
<LocalTooltipProvider
value={{
isOpen,
setIsOpen,
x,
y,
followCursor,
followCursorSpringOptions,
}}
>
<TooltipPrimitive.Root
data-slot="tooltip"
{...props}
onOpenChange={setIsOpen}
/>
</LocalTooltipProvider>
);
}
type TooltipTriggerProps = React.ComponentProps<
typeof TooltipPrimitive.Trigger
>;
function TooltipTrigger({ onMouseMove, ...props }: TooltipTriggerProps) {
const { x, y, followCursor } = useTooltip();
const handleMouseMove = (event: React.MouseEvent<HTMLButtonElement>) => {
onMouseMove?.(event);
const target = event.currentTarget.getBoundingClientRect();
if (followCursor === 'x' || followCursor === true) {
const eventOffsetX = event.clientX - target.left;
const offsetXFromCenter = (eventOffsetX - target.width / 2) / 2;
x.set(offsetXFromCenter);
}
if (followCursor === 'y' || followCursor === true) {
const eventOffsetY = event.clientY - target.top;
const offsetYFromCenter = (eventOffsetY - target.height / 2) / 2;
y.set(offsetYFromCenter);
}
};
return (
<TooltipPrimitive.Trigger
data-slot="tooltip-trigger"
onMouseMove={handleMouseMove}
{...props}
/>
);
}
type TooltipPortalProps = Omit<
React.ComponentProps<typeof TooltipPrimitive.Portal>,
'forceMount'
>;
function TooltipPortal(props: TooltipPortalProps) {
const { isOpen } = useTooltip();
return (
<AnimatePresence>
{isOpen && (
<TooltipPrimitive.Portal
forceMount
data-slot="tooltip-portal"
{...props}
/>
)}
</AnimatePresence>
);
}
type TooltipContentProps = Omit<
React.ComponentProps<typeof TooltipPrimitive.Content>,
'forceMount' | 'asChild'
> &
HTMLMotionProps<'div'>;
function TooltipContent({
onEscapeKeyDown,
onPointerDownOutside,
side,
sideOffset,
align,
alignOffset,
avoidCollisions,
collisionBoundary,
collisionPadding,
arrowPadding,
sticky,
hideWhenDetached,
style,
transition = { type: 'spring', stiffness: 300, damping: 25 },
...props
}: TooltipContentProps) {
const { x, y, followCursor, followCursorSpringOptions } = useTooltip();
const translateX = useSpring(x, followCursorSpringOptions);
const translateY = useSpring(y, followCursorSpringOptions);
return (
<TooltipPrimitive.Content
asChild
forceMount
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
avoidCollisions={avoidCollisions}
collisionBoundary={collisionBoundary}
collisionPadding={collisionPadding}
arrowPadding={arrowPadding}
sticky={sticky}
hideWhenDetached={hideWhenDetached}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
>
<motion.div
key="popover-content"
data-slot="popover-content"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={transition}
style={{
x:
followCursor === 'x' || followCursor === true
? translateX
: undefined,
y:
followCursor === 'y' || followCursor === true
? translateY
: undefined,
...style,
}}
{...props}
/>
</TooltipPrimitive.Content>
);
}
type TooltipArrowProps = React.ComponentProps<typeof TooltipPrimitive.Arrow>;
function TooltipArrow(props: TooltipArrowProps) {
return <TooltipPrimitive.Arrow data-slot="tooltip-arrow" {...props} />;
}
export {
TooltipProvider,
Tooltip,
TooltipTrigger,
TooltipPortal,
TooltipContent,
TooltipArrow,
useTooltip,
type TooltipProviderProps,
type TooltipProps,
type TooltipTriggerProps,
type TooltipPortalProps,
type TooltipContentProps,
type TooltipArrowProps,
type TooltipContextType,
};
@@ -0,0 +1,119 @@
'use client';
import * as React from 'react';
import { useMotionValue, useSpring, type SpringOptions } from 'motion/react';
import {
useIsInView,
type UseIsInViewOptions,
} from '@/hooks/use-is-in-view';
type CountingNumberProps = Omit<React.ComponentProps<'span'>, 'children'> & {
number: number;
fromNumber?: number;
padStart?: boolean;
decimalSeparator?: string;
decimalPlaces?: number;
transition?: SpringOptions;
delay?: number;
initiallyStable?: boolean;
} & UseIsInViewOptions;
function CountingNumber({
ref,
number,
fromNumber = 0,
padStart = false,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
decimalSeparator = '.',
transition = { stiffness: 90, damping: 50 },
decimalPlaces = 0,
delay = 0,
initiallyStable = false,
...props
}: CountingNumberProps) {
const { ref: localRef, isInView } = useIsInView(
ref as React.Ref<HTMLElement>,
{
inView,
inViewOnce,
inViewMargin,
},
);
const numberStr = number.toString();
const decimals =
typeof decimalPlaces === 'number'
? decimalPlaces
: numberStr.includes('.')
? (numberStr.split('.')[1]?.length ?? 0)
: 0;
const motionVal = useMotionValue(initiallyStable ? number : fromNumber);
const springVal = useSpring(motionVal, transition);
React.useEffect(() => {
const timeoutId = setTimeout(() => {
if (isInView) motionVal.set(number);
}, delay);
return () => clearTimeout(timeoutId);
}, [isInView, number, motionVal, delay]);
React.useEffect(() => {
const unsubscribe = springVal.on('change', (latest) => {
if (localRef.current) {
let formatted =
decimals > 0
? latest.toFixed(decimals)
: Math.round(latest).toString();
if (decimals > 0) {
formatted = formatted.replace('.', decimalSeparator);
}
if (padStart) {
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
const [intPart, fracPart] = formatted.split(decimalSeparator);
const paddedInt = intPart?.padStart(finalIntLength, '0') ?? '';
formatted = fracPart
? `${paddedInt}${decimalSeparator}${fracPart}`
: paddedInt;
}
localRef.current.textContent = formatted;
}
});
return () => unsubscribe();
}, [springVal, decimals, padStart, number, decimalSeparator, localRef]);
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
const formatValue = (val: number) => {
let out = decimals > 0 ? val.toFixed(decimals) : Math.round(val).toString();
if (decimals > 0) out = out.replace('.', decimalSeparator);
if (padStart) {
const [intPart, fracPart] = out.split(decimalSeparator);
const paddedInt = (intPart ?? '').padStart(finalIntLength, '0');
out = fracPart ? `${paddedInt}${decimalSeparator}${fracPart}` : paddedInt;
}
return out;
};
const zeroText = padStart
? '0'.padStart(finalIntLength, '0') +
(decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '')
: '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '');
const initialText = initiallyStable ? formatValue(number) : zeroText;
return (
<span ref={localRef} data-slot="counting-number" {...props}>
{initialText}
</span>
);
}
export { CountingNumber, type CountingNumberProps };
@@ -0,0 +1,353 @@
'use client';
import * as React from 'react';
import {
useSpring,
useTransform,
motion,
useMotionValue,
type MotionValue,
type SpringOptions,
type HTMLMotionProps,
} from 'motion/react';
import useMeasure from 'react-use-measure';
import {
useIsInView,
type UseIsInViewOptions,
} from '@/hooks/use-is-in-view';
type SlidingNumberRollerProps = {
prevValue: number;
value: number;
place: number;
transition: SpringOptions;
delay?: number;
};
function SlidingNumberRoller({
prevValue,
value,
place,
transition,
delay = 0,
}: SlidingNumberRollerProps) {
const startNumber = Math.floor(prevValue / place) % 10;
const targetNumber = Math.floor(value / place) % 10;
const animatedValue = useSpring(startNumber, transition);
React.useEffect(() => {
const timeoutId = setTimeout(() => {
animatedValue.set(targetNumber);
}, delay);
return () => clearTimeout(timeoutId);
}, [targetNumber, animatedValue, delay]);
const [measureRef, { height }] = useMeasure();
return (
<span
ref={measureRef}
data-slot="sliding-number-roller"
style={{
position: 'relative',
display: 'inline-block',
width: '1ch',
overflowX: 'visible',
overflowY: 'clip',
lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
}}
>
<span style={{ visibility: 'hidden' }}>0</span>
{Array.from({ length: 10 }, (_, i) => (
<SlidingNumberDisplay
key={i}
motionValue={animatedValue}
number={i}
height={height}
transition={transition}
/>
))}
</span>
);
}
type SlidingNumberDisplayProps = {
motionValue: MotionValue<number>;
number: number;
height: number;
transition: SpringOptions;
};
function SlidingNumberDisplay({
motionValue,
number,
height,
transition,
}: SlidingNumberDisplayProps) {
const y = useTransform(motionValue, (latest) => {
if (!height) return 0;
const currentNumber = latest % 10;
const offset = (10 + number - currentNumber) % 10;
let translateY = offset * height;
if (offset > 5) translateY -= 10 * height;
return translateY;
});
if (!height) {
return (
<span style={{ visibility: 'hidden', position: 'absolute' }}>
{number}
</span>
);
}
return (
<motion.span
data-slot="sliding-number-display"
style={{
y,
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
transition={{ ...transition, type: 'spring' }}
>
{number}
</motion.span>
);
}
type SlidingNumberProps = Omit<HTMLMotionProps<'span'>, 'children'> & {
number: number;
fromNumber?: number;
onNumberChange?: (number: number) => void;
padStart?: boolean;
decimalSeparator?: string;
decimalPlaces?: number;
thousandSeparator?: string;
transition?: SpringOptions;
delay?: number;
initiallyStable?: boolean;
} & UseIsInViewOptions;
function SlidingNumber({
ref,
number,
fromNumber,
onNumberChange,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
padStart = false,
decimalSeparator = '.',
decimalPlaces = 0,
thousandSeparator,
transition = { stiffness: 200, damping: 20, mass: 0.4 },
delay = 0,
initiallyStable = false,
...props
}: SlidingNumberProps) {
const { ref: localRef, isInView } = useIsInView(
ref as React.Ref<HTMLElement>,
{
inView,
inViewOnce,
inViewMargin,
},
);
const initialNumeric = Math.abs(Number(number));
const prevNumberRef = React.useRef<number>(
initiallyStable ? initialNumeric : 0,
);
const hasAnimated = fromNumber !== undefined;
const motionVal = useMotionValue(
initiallyStable ? initialNumeric : (fromNumber ?? 0),
);
const springVal = useSpring(motionVal, { stiffness: 90, damping: 50 });
const skippedInitialWhenStable = React.useRef(false);
React.useEffect(() => {
if (!hasAnimated) return;
if (initiallyStable && !skippedInitialWhenStable.current) {
skippedInitialWhenStable.current = true;
return;
}
const timeoutId = setTimeout(() => {
if (isInView) motionVal.set(number);
}, delay);
return () => clearTimeout(timeoutId);
}, [hasAnimated, initiallyStable, isInView, number, motionVal, delay]);
const [effectiveNumber, setEffectiveNumber] = React.useState<number>(
initiallyStable ? initialNumeric : 0,
);
React.useEffect(() => {
if (hasAnimated) {
const inferredDecimals =
typeof decimalPlaces === 'number' && decimalPlaces >= 0
? decimalPlaces
: (() => {
const s = String(number);
const idx = s.indexOf('.');
return idx >= 0 ? s.length - idx - 1 : 0;
})();
const factor = Math.pow(10, inferredDecimals);
const unsubscribe = springVal.on('change', (latest: number) => {
const newValue =
inferredDecimals > 0
? Math.round(latest * factor) / factor
: Math.round(latest);
if (effectiveNumber !== newValue) {
setEffectiveNumber(newValue);
onNumberChange?.(newValue);
}
});
return () => unsubscribe();
} else {
setEffectiveNumber(
initiallyStable ? initialNumeric : !isInView ? 0 : initialNumeric,
);
}
}, [
hasAnimated,
springVal,
isInView,
number,
decimalPlaces,
onNumberChange,
effectiveNumber,
initiallyStable,
initialNumeric,
]);
const formatNumber = React.useCallback(
(num: number) =>
decimalPlaces != null ? num.toFixed(decimalPlaces) : num.toString(),
[decimalPlaces],
);
const numberStr = formatNumber(effectiveNumber);
const [newIntStrRaw, newDecStrRaw = ''] = numberStr.split('.');
const finalIntLength = padStart
? Math.max(
Math.floor(Math.abs(number)).toString().length,
newIntStrRaw.length,
)
: newIntStrRaw.length;
const newIntStr = padStart
? newIntStrRaw.padStart(finalIntLength, '0')
: newIntStrRaw;
const prevFormatted = formatNumber(prevNumberRef.current);
const [prevIntStrRaw = '', prevDecStrRaw = ''] = prevFormatted.split('.');
const prevIntStr = padStart
? prevIntStrRaw.padStart(finalIntLength, '0')
: prevIntStrRaw;
const adjustedPrevInt = React.useMemo(() => {
return prevIntStr.length > finalIntLength
? prevIntStr.slice(-finalIntLength)
: prevIntStr.padStart(finalIntLength, '0');
}, [prevIntStr, finalIntLength]);
const adjustedPrevDec = React.useMemo(() => {
if (!newDecStrRaw) return '';
return prevDecStrRaw.length > newDecStrRaw.length
? prevDecStrRaw.slice(0, newDecStrRaw.length)
: prevDecStrRaw.padEnd(newDecStrRaw.length, '0');
}, [prevDecStrRaw, newDecStrRaw]);
React.useEffect(() => {
if (isInView || initiallyStable) {
prevNumberRef.current = effectiveNumber;
}
}, [effectiveNumber, isInView, initiallyStable]);
const intPlaces = React.useMemo(
() =>
Array.from({ length: finalIntLength }, (_, i) =>
Math.pow(10, finalIntLength - i - 1),
),
[finalIntLength],
);
const decPlaces = React.useMemo(
() =>
newDecStrRaw
? Array.from({ length: newDecStrRaw.length }, (_, i) =>
Math.pow(10, newDecStrRaw.length - i - 1),
)
: [],
[newDecStrRaw],
);
const newDecValue = newDecStrRaw ? parseInt(newDecStrRaw, 10) : 0;
const prevDecValue = adjustedPrevDec ? parseInt(adjustedPrevDec, 10) : 0;
return (
<motion.span
ref={localRef}
data-slot="sliding-number"
style={{
display: 'inline-flex',
alignItems: 'center',
}}
{...props}
>
{isInView && Number(number) < 0 && (
<span style={{ marginRight: '0.25rem' }}>-</span>
)}
{intPlaces.map((place, idx) => {
const digitsToRight = intPlaces.length - idx - 1;
const isSeparatorPosition =
typeof thousandSeparator !== 'undefined' &&
digitsToRight > 0 &&
digitsToRight % 3 === 0;
return (
<React.Fragment key={`int-${place}`}>
<SlidingNumberRoller
prevValue={parseInt(adjustedPrevInt, 10)}
value={parseInt(newIntStr ?? '0', 10)}
place={place}
transition={transition}
/>
{isSeparatorPosition && <span>{thousandSeparator}</span>}
</React.Fragment>
);
})}
{newDecStrRaw && (
<>
<span>{decimalSeparator}</span>
{decPlaces.map((place) => (
<SlidingNumberRoller
key={`dec-${place}`}
prevValue={prevDecValue}
value={newDecValue}
place={place}
transition={transition}
delay={delay}
/>
))}
</>
)}
</motion.span>
);
}
export { SlidingNumber, type SlidingNumberProps };
+45 -52
View File
@@ -1,14 +1,13 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { motion } from 'motion/react';
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
@@ -16,60 +15,59 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
>(({ className, children, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
<AlertDialogPrimitive.Content ref={ref} asChild {...props}>
<motion.div
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg',
className
)}
initial={{ opacity: 0, scale: 0.92, y: -12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
>
{children}
</motion.div>
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
className={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
@@ -77,11 +75,11 @@ const AlertDialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
className={cn('text-lg font-semibold', className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
@@ -89,12 +87,11 @@ const AlertDialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
@@ -105,8 +102,8 @@ const AlertDialogAction = React.forwardRef<
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
@@ -114,15 +111,11 @@ const AlertDialogCancel = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
@@ -136,4 +129,4 @@ export {
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
};
+41 -87
View File
@@ -1,112 +1,66 @@
"use client"
'use client';
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as React from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
import {
Dialog,
DialogPortal,
DialogOverlay as AnimateDialogOverlay,
DialogClose,
DialogTrigger,
DialogContent as AnimateDialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/animate-ui/primitives/radix/dialog';
// Drop-in DialogContent that bundles portal + overlay + close button
// while delegating animation to animate-ui's spring-based dialog
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
HTMLDivElement,
React.ComponentProps<typeof AnimateDialogContent>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
<AnimateDialogOverlay className="fixed inset-0 z-50 bg-black/80" />
<AnimateDialogContent
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogClose>
</AnimateDialogContent>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
));
DialogContent.displayName = 'DialogContent';
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
const DialogOverlay = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof AnimateDialogOverlay>
>((props, ref) => (
<AnimateDialogOverlay ref={ref} {...props} />
));
DialogOverlay.displayName = 'DialogOverlay';
// Override DialogFooter to add proper spacing (animate-ui's version has no classes)
const DialogFooter = ({ className, ...props }: React.ComponentProps<'div'>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
);
DialogFooter.displayName = 'DialogFooter';
export {
Dialog,
@@ -119,4 +73,4 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
}
};
+27 -23
View File
@@ -1,29 +1,33 @@
"use client"
'use client';
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
import {
Switch as AnimateSwitch,
SwitchThumb,
type SwitchProps as AnimateSwitchProps,
} from '@/components/animate-ui/primitives/radix/switch';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
type SwitchProps = Omit<AnimateSwitchProps, 'children'>;
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
({ className, ...props }, ref) => (
<AnimateSwitch
ref={ref}
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
{...props}
>
<SwitchThumb
className="pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
pressedAnimation={{ scale: 1.15 }}
/>
</AnimateSwitch>
)
);
Switch.displayName = 'Switch';
export { Switch }
export { Switch };
+32 -24
View File
@@ -1,53 +1,61 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from 'react';
import { Tabs as RadixTabs } from 'radix-ui';
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
import {
Tabs,
TabsHighlight,
TabsHighlightItem,
TabsList as AnimateTabsList,
TabsTrigger as AnimateTabsTrigger,
} from '@/components/animate-ui/primitives/radix/tabs';
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
HTMLDivElement,
React.ComponentProps<typeof AnimateTabsList>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
<AnimateTabsList
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
));
TabsList.displayName = 'TabsList';
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
HTMLButtonElement,
React.ComponentProps<typeof AnimateTabsTrigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
<AnimateTabsTrigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
));
TabsTrigger.displayName = 'TabsTrigger';
// Custom TabsContent: uses Radix directly (no Framer Motion `layout` prop)
// to prevent unintended height expansion when switching tabs.
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
HTMLDivElement,
React.ComponentProps<typeof RadixTabs.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
<RadixTabs.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:duration-200',
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
));
TabsContent.displayName = 'TabsContent';
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent, TabsHighlight, TabsHighlightItem };
+20 -14
View File
@@ -1,30 +1,36 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
'use client';
import { cn } from "@/lib/utils"
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
// Use Radix primitives directly to avoid animate-ui context dependencies
// that can cause crashes in certain component tree configurations.
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
HTMLDivElement,
React.ComponentProps<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground',
'origin-[--radix-tooltip-content-transform-origin]',
'animate-in fade-in-0 zoom-in-95 duration-150',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
));
TooltipContent.displayName = 'TooltipContent';
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+102
View File
@@ -0,0 +1,102 @@
'use client';
import * as React from 'react';
type AutoHeightOptions = {
includeParentBox?: boolean;
includeSelfBox?: boolean;
};
export function useAutoHeight<T extends HTMLElement = HTMLDivElement>(
deps: React.DependencyList = [],
options: AutoHeightOptions = {
includeParentBox: true,
includeSelfBox: false,
},
) {
const ref = React.useRef<T | null>(null);
const roRef = React.useRef<ResizeObserver | null>(null);
const [height, setHeight] = React.useState(0);
const measure = React.useCallback(() => {
const el = ref.current;
if (!el) return 0;
const base = el.getBoundingClientRect().height || 0;
let extra = 0;
if (options.includeParentBox && el.parentElement) {
const cs = getComputedStyle(el.parentElement);
const paddingY =
(parseFloat(cs.paddingTop || '0') || 0) +
(parseFloat(cs.paddingBottom || '0') || 0);
const borderY =
(parseFloat(cs.borderTopWidth || '0') || 0) +
(parseFloat(cs.borderBottomWidth || '0') || 0);
const isBorderBox = cs.boxSizing === 'border-box';
if (isBorderBox) {
extra += paddingY + borderY;
}
}
if (options.includeSelfBox) {
const cs = getComputedStyle(el);
const paddingY =
(parseFloat(cs.paddingTop || '0') || 0) +
(parseFloat(cs.paddingBottom || '0') || 0);
const borderY =
(parseFloat(cs.borderTopWidth || '0') || 0) +
(parseFloat(cs.borderBottomWidth || '0') || 0);
const isBorderBox = cs.boxSizing === 'border-box';
if (isBorderBox) {
extra += paddingY + borderY;
}
}
const dpr =
typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
const total = Math.ceil((base + extra) * dpr) / dpr;
return total;
}, [options.includeParentBox, options.includeSelfBox]);
React.useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setHeight(measure());
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
const ro = new ResizeObserver(() => {
const next = measure();
requestAnimationFrame(() => setHeight(next));
});
ro.observe(el);
if (options.includeParentBox && el.parentElement) {
ro.observe(el.parentElement);
}
roRef.current = ro;
return () => {
ro.disconnect();
roRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
React.useLayoutEffect(() => {
if (height === 0) {
const next = measure();
if (next !== 0) setHeight(next);
}
}, [height, measure]);
return { ref, height } as const;
}
@@ -0,0 +1,33 @@
import * as React from 'react';
interface CommonControlledStateProps<T> {
value?: T;
defaultValue?: T;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useControlledState<T, Rest extends any[] = []>(
props: CommonControlledStateProps<T> & {
onChange?: (value: T, ...args: Rest) => void;
},
): readonly [T, (next: T, ...args: Rest) => void] {
const { value, defaultValue, onChange } = props;
const [state, setInternalState] = React.useState<T>(
value !== undefined ? value : (defaultValue as T),
);
React.useEffect(() => {
if (value !== undefined) setInternalState(value);
}, [value]);
const setState = React.useCallback(
(next: T, ...args: Rest) => {
setInternalState(next);
onChange?.(next, ...args);
},
[onChange],
);
return [state, setState] as const;
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import * as React from 'react';
type DataStateValue = string | boolean | null;
function parseDatasetValue(value: string | null): DataStateValue {
if (value === null) return null;
if (value === '' || value === 'true') return true;
if (value === 'false') return false;
return value;
}
function useDataState<T extends HTMLElement = HTMLElement>(
key: string,
forwardedRef?: React.Ref<T | null>,
onChange?: (value: DataStateValue) => void,
): [DataStateValue, React.RefObject<T | null>] {
const localRef = React.useRef<T | null>(null);
React.useImperativeHandle(forwardedRef, () => localRef.current as T);
const getSnapshot = (): DataStateValue => {
const el = localRef.current;
return el ? parseDatasetValue(el.getAttribute(`data-${key}`)) : null;
};
const subscribe = (callback: () => void) => {
const el = localRef.current;
if (!el) return () => {};
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.attributeName === `data-${key}`) {
callback();
break;
}
}
});
observer.observe(el, {
attributes: true,
attributeFilter: [`data-${key}`],
});
return () => observer.disconnect();
};
const value = React.useSyncExternalStore(subscribe, getSnapshot);
React.useEffect(() => {
if (onChange) onChange(value);
}, [value, onChange]);
return [value, localRef];
}
export { useDataState, type DataStateValue };
+25
View File
@@ -0,0 +1,25 @@
import * as React from 'react';
import { useInView, type UseInViewOptions } from 'motion/react';
interface UseIsInViewOptions {
inView?: boolean;
inViewOnce?: boolean;
inViewMargin?: UseInViewOptions['margin'];
}
function useIsInView<T extends HTMLElement = HTMLElement>(
ref: React.Ref<T>,
options: UseIsInViewOptions = {},
) {
const { inView, inViewOnce = false, inViewMargin = '0px' } = options;
const localRef = React.useRef<T>(null);
React.useImperativeHandle(ref, () => localRef.current as T);
const inViewResult = useInView(localRef, {
once: inViewOnce,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
return { ref: localRef, isInView };
}
export { useIsInView, type UseIsInViewOptions };
+169 -37
View File
@@ -4,6 +4,9 @@
@custom-variant dark (&:is(.dark *));
/* ─────────────────────────────────────────────────────────────
LIGHT THEME
───────────────────────────────────────────────────────────── */
:root {
--background: oklch(1 0 0);
--foreground: oklch(0 0 0);
@@ -23,12 +26,21 @@
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.9200 0 0);
--input: oklch(0.9400 0 0);
--ring: oklch(0 0 0);
--ring: oklch(0.50 0.14 200);
/* Brand — technical cyan, the signature accent */
--brand: oklch(0.50 0.14 200);
--brand-foreground: oklch(1 0 0);
--brand-muted: oklch(0.50 0.14 200 / 0.12);
/* Charts */
--chart-1: oklch(0.8100 0.1700 75.3500);
--chart-2: oklch(0.5500 0.2200 264.5300);
--chart-3: oklch(0.7200 0 0);
--chart-4: oklch(0.9200 0 0);
--chart-5: oklch(0.5600 0 0);
/* Sidebar */
--sidebar: oklch(0.9900 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0 0 0);
@@ -36,11 +48,17 @@
--sidebar-accent: oklch(0.9400 0 0);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(0.9400 0 0);
--sidebar-ring: oklch(0 0 0);
--font-sans: Geist, sans-serif;
--sidebar-ring: oklch(0.50 0.14 200);
/* Typography */
--font-sans: 'Geist', sans-serif;
--font-serif: Georgia, serif;
--font-mono: Geist Mono, monospace;
--font-mono: 'Geist Mono', monospace;
/* Shape */
--radius: 0.5rem;
/* Shadows */
--shadow-x: 0px;
--shadow-y: 1px;
--shadow-blur: 2px;
@@ -55,14 +73,26 @@
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
/* Motion timing */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--duration-fast: 150ms;
--duration-base: 220ms;
--duration-slow: 350ms;
--tracking-normal: 0em;
--spacing: 0.25rem;
}
/* ─────────────────────────────────────────────────────────────
DARK THEME
───────────────────────────────────────────────────────────── */
.dark {
--background: oklch(0.1400 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0.1400 0 0);
--card: oklch(0.1600 0 0);
--card-foreground: oklch(1 0 0);
--popover: oklch(0.1800 0 0);
--popover-foreground: oklch(1 0 0);
@@ -78,12 +108,21 @@
--destructive-foreground: oklch(0 0 0);
--border: oklch(0.2600 0 0);
--input: oklch(0.3200 0 0);
--ring: oklch(0.7200 0 0);
--ring: oklch(0.72 0.14 200);
/* Brand — bright technical cyan on dark backgrounds */
--brand: oklch(0.72 0.14 200);
--brand-foreground: oklch(0.05 0 0);
--brand-muted: oklch(0.72 0.14 200 / 0.12);
/* Charts */
--chart-1: oklch(0.8100 0.1700 75.3500);
--chart-2: oklch(0.5800 0.2100 260.8400);
--chart-3: oklch(0.5600 0 0);
--chart-4: oklch(0.4400 0 0);
--chart-5: oklch(0.9200 0 0);
/* Sidebar */
--sidebar: oklch(0.1800 0 0);
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(1 0 0);
@@ -91,27 +130,36 @@
--sidebar-accent: oklch(0.3200 0 0);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.3200 0 0);
--sidebar-ring: oklch(0.7200 0 0);
--font-sans: Geist, sans-serif;
--sidebar-ring: oklch(0.72 0.14 200);
/* Typography */
--font-sans: 'Geist', sans-serif;
--font-serif: Georgia, serif;
--font-mono: Geist Mono, monospace;
--font-mono: 'Geist Mono', monospace;
/* Shape */
--radius: 0.5rem;
/* Shadows — deeper on dark backgrounds */
--shadow-x: 0px;
--shadow-y: 1px;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-opacity: 0.18;
--shadow-opacity: 0.35;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.18);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.18);
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 1px 2px -1px hsl(0 0% 0% / 0.35);
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 1px 2px -1px hsl(0 0% 0% / 0.35);
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 2px 4px -1px hsl(0 0% 0% / 0.35);
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 4px 6px -1px hsl(0 0% 0% / 0.35);
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.35), 0px 8px 10px -1px hsl(0 0% 0% / 0.35);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.65);
}
/* ─────────────────────────────────────────────────────────────
TAILWIND THEME MAPPING
───────────────────────────────────────────────────────────── */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -132,6 +180,9 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-brand-muted: var(--brand-muted);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
@@ -165,6 +216,9 @@
--shadow-2xl: var(--shadow-2xl);
}
/* ─────────────────────────────────────────────────────────────
BASE STYLES
───────────────────────────────────────────────────────────── */
@layer base {
:root {
color-scheme: dark;
@@ -179,22 +233,9 @@
}
}
/* Custom Dark Mode Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted) / 0.8);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
/* ─────────────────────────────────────────────────────────────
TYPOGRAPHY & RENDERING
───────────────────────────────────────────────────────────── */
html,
body,
#root {
@@ -204,7 +245,8 @@ body,
}
body {
font-family: var(--font-sans), system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-family: var(--font-sans), system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
@@ -213,7 +255,97 @@ body {
-moz-osx-font-smoothing: grayscale;
}
/* xterm.js styles */
/* ─────────────────────────────────────────────────────────────
SCROLLBAR
───────────────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(0.40 0 0 / 0.5);
border-radius: 4px;
transition: background var(--duration-fast) ease;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.55 0 0 / 0.7);
}
.dark ::-webkit-scrollbar-thumb {
background: oklch(0.35 0 0 / 0.6);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: oklch(0.50 0 0 / 0.8);
}
/* ─────────────────────────────────────────────────────────────
ANIMATION KEYFRAMES
───────────────────────────────────────────────────────────── */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-down {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-in-left {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes scale-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
/* ─────────────────────────────────────────────────────────────
ANIMATION UTILITIES
───────────────────────────────────────────────────────────── */
.animate-fade-in {
animation: fade-in var(--duration-base) var(--ease-out-expo) both;
}
.animate-fade-up {
animation: fade-up var(--duration-base) var(--ease-out-expo) both;
}
.animate-fade-down {
animation: fade-down var(--duration-base) var(--ease-out-expo) both;
}
.animate-slide-in-left {
animation: slide-in-left var(--duration-base) var(--ease-out-expo) both;
}
.animate-scale-in {
animation: scale-in var(--duration-fast) var(--ease-spring) both;
}
/* Stagger delay helpers */
.animate-delay-50 { animation-delay: 50ms; }
.animate-delay-100 { animation-delay: 100ms; }
.animate-delay-150 { animation-delay: 150ms; }
.animate-delay-200 { animation-delay: 200ms; }
.animate-delay-300 { animation-delay: 300ms; }
/* ─────────────────────────────────────────────────────────────
PREFERS REDUCED MOTION — zero-cost accessibility
───────────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ─────────────────────────────────────────────────────────────
XTERM.JS
───────────────────────────────────────────────────────────── */
.xterm {
padding: 0;
}
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from 'react';
function getStrictContext<T>(
name?: string,
): readonly [
({
value,
children,
}: {
value: T;
children?: React.ReactNode;
}) => React.JSX.Element,
() => T,
] {
const Context = React.createContext<T | undefined>(undefined);
const Provider = ({
value,
children,
}: {
value: T;
children?: React.ReactNode;
}) => <Context.Provider value={value}>{children}</Context.Provider>;
const useSafeContext = () => {
const ctx = React.useContext(Context);
if (ctx === undefined) {
throw new Error(`useContext must be used within ${name ?? 'a Provider'}`);
}
return ctx;
};
return [Provider, useSafeContext] as const;
}
export { getStrictContext };