mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 20:29:10 +00:00
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:
+55
-43
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
@@ -19,5 +19,7 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
"registries": {
|
||||
"@animate-ui": "https://animate-ui.com/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+1593
-6
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user