5 Commits
Author SHA1 Message Date
adminsandClaude Opus 5.5 62d76916ca Route storefront checkout to a local stub outside production
The checkout form and payment-method lookup were hardcoded to the live
payment router, so any local LMS (next dev included) created real
orders: a stray test click on 20261111 produced order 1794380127.

- resolvePayBase(): NEXT_PUBLIC_PAY_BASE_URL if set, otherwise the live
  router for production builds and /dev/pay-stub for everything else.
- /dev/pay-stub/pay/{methods,order}: dev-only stand-ins (404 in
  production) that make no outbound calls and store nothing.
- Document in CLAUDE.md that a local `next build && next start` still
  hits the live router unless NEXT_PUBLIC_PAY_BASE_URL is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:14:12 +05:00
adminsandClaude Opus 5.5 ba6da38b9c Keep Tab inside the admin command menu
The search input is the only tabbable element in the menu, and Base UI's
focus guards let Tab leak out of a single-element trap: focus walked to
the sidebar while the menu stayed open. Arrow keys drive the selection
in a command menu, so Tab now stays on the input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:11:19 +05:00
adminsandClaude Opus 5.5 7a231cf92f Add pick-ui-library skill with project constraints
Copied from emilkowalski/skills (MIT, commit 715c180), invoked manually
only. Appended a project section: shadcn base-nova on Base UI, DS-2
rules, one animated element per page, no new deps without approval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:40:30 +05:00
adminsandClaude Opus 5.5 197bbb80ec Limit motion to one animated element per page (DS-2)
- Course navigation: only the completion percent animates; the
  "N of M lessons" counter is static. NumberFlow is enabled only in the
  visible copy of the navigation (drawer on phones, sidebar otherwise).
- globals.css: disable Vaul drawer and overlay animation under
  prefers-reduced-motion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:26:44 +05:00
adminsandClaude Opus 5.5 09b069cbb5 Add admin command menu, mobile drawer and animated course progress
- cmdk 1.1.1: Cmd/Ctrl+K command menu in the admin panel (students,
  courses, lessons, admin sections) plus a "Search" button in the sidebar.
  New read-only endpoint GET /api/admin/search with the same admin role
  check as /api/admin/export-users; query length 2-100 chars.
- vaul 1.1.2: ResponsiveDialog wrapper (Base UI Dialog on desktop, bottom
  drawer below 768px) styled per DS-2. Used for the storefront checkout on
  the dashboard and for course navigation on phones (tablet and desktop
  unchanged).
- @number-flow/react 0.6.2: animated "N of M lessons" and completion
  percent in course navigation; respects prefers-reduced-motion.

No migrations. Not deployed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:25:52 +05:00
19 changed files with 1690 additions and 188 deletions
+95
View File
@@ -0,0 +1,95 @@
---
name: pick-ui-library
description: Pick the right library for a given frontend task from a curated, opinionated list — numbers, OTP inputs, charts, command menus, virtualization, drag and drop, toasts, state, styling, and more. Only runs when explicitly invoked; it does not trigger on its own.
disable-model-invocation: true
---
# Picking The Right Library
## Initial Response
When this skill is first invoked without a specific question, respond only with:
> I'm ready to pick the right library for your task, my picks come from Emil Kowalski's curated list.
Do not provide any other information until the user asks a question.
A lookup skill. When invoked with a task ("I need toasts", "what should I use for drag and drop?"), match the task to the curated list below and recommend the library. These are deliberate, taste-driven picks — don't substitute alternatives outside this list unless the user asks for one or the task genuinely isn't covered.
## How to use this
1. **Identify the task**, not the library the user named. "I need to show a dropdown" is a UI-primitives task (base-ui), even if they asked about something else.
2. **Check what's already installed.** Look at `package.json` first. If the project already uses a listed library, use it. If it uses a competitor (e.g. react-window instead of Virtuoso), flag the recommendation but don't churn the dependency without being asked.
3. **Recommend one library**, state what it's for in one sentence, and install/wire it up if that's part of the request. Don't present a menu of options when the list has a clear answer.
4. If the task isn't covered by the list, say so explicitly and recommend from your own knowledge — but be clear you've left the curated list.
## The list
### UI components & primitives
| Task | Library |
| --- | --- |
| Unstyled, accessible UI components (dialogs, popovers, menus, selects…) | [base-ui](https://base-ui.com) |
| Command menus (⌘K palettes) | [cmdk](https://cmdk.paco.me) |
| Toasts / notifications | [Sonner](https://sonner.emilkowal.ski) |
| One-time password / verification code inputs | [input-otp](https://input-otp.rodz.dev) |
| Customizable GUIs / control panels | [Leva](https://github.com/pmndrs/leva) — [dialkit](https://joshpuckett.me/dialkit) is an alternative |
### Motion & visuals
| Task | Library |
| --- | --- |
| General-purpose animation (springs, layout animations, enter/exit) | [motion](https://motion.dev) (Framer Motion) |
| Animating numbers (counters, prices, stats) | [NumberFlow](https://number-flow.barvian.me) |
| Animated text components | [torph](https://torph.lochie.me/) |
| 3D globes | [Cobe](https://cobe.vercel.app) |
| Dynamic OG images (HTML/CSS → SVG/PNG) | [Satori](https://github.com/vercel/satori) |
| Syntax highlighting | [shiki](https://shiki.style) |
Reach for motion when you need springs, layout animations, exit animations, or gesture-driven values. A simple hover or fade doesn't need it — plain CSS transitions are the right tool there.
### Charts
| Task | Library |
| --- | --- |
| Real-time / streaming charts | [Liveline](https://github.com/benjitaylor/liveline) |
| General charts (static or interactive dashboards) | [recharts](https://recharts.org) |
The split: if data points arrive live and the chart scrolls with time, use Liveline. Everything else is recharts.
### Interaction & performance
| Task | Library |
| --- | --- |
| Drag and drop | [dnd kit](https://dndkit.com) |
| Virtualization (long lists, large tables) | [Virtuoso](https://virtuoso.dev) |
### State & styling
| Task | Library |
| --- | --- |
| State management | [zustand](https://zustand.docs.pmnd.rs) |
| Constructing `className` strings conditionally | [clsx](https://github.com/lukeed/clsx) |
| Type-safe, variant-driven styling for Tailwind | [cva](https://cva.style) |
| Theme switching / dark mode (no flash on load) | [next-themes](https://github.com/pacocoursey/next-themes) |
The styling split: clsx for ad-hoc conditional classes; cva when a component has real variants (size, intent, state) that deserve a typed API. They compose — cva uses clsx-style inputs internally.
## Common mismatches to catch
- **Toasts built by hand or with a modal library** → Sonner exists for exactly this.
- **A `<div>`-based dropdown/dialog with manual focus handling** → base-ui, which handles accessibility, focus trapping, and dismissal.
- **Animating a number by re-rendering text** → NumberFlow handles digit transitions properly.
- **Rendering a 1,000+ row list directly** → Virtuoso before reaching for pagination hacks.
- **A `useState`-per-component web of props for shared state** → zustand.
- **Template-literal className ternaries three conditions deep** → clsx (or cva if it's variant-shaped).
## Project constraints (Second Brain LMS — override the list above)
Source: emilkowalski/skills @ 715c180 (MIT). Section added for this repo on 20261111.
- UI primitives: this project uses shadcn (style "base-nova") on top of @base-ui/react. For dialogs, menus, selects and popovers run `npx shadcn add <component>` and restyle it to DS-2; do not import raw base-ui parts in feature code. Never add Radix directly.
- Design system DS-2 Aubade (SecondBrainTech/02-Стандарты/Дизайн-LMS/DESIGN.md) wins over any library default: radius 2px, 2px borders, Fira Mono, hard offset shadows (4px 4px 0, no blur), no gradients, one accent #E8F0D8, red only for errors.
- Motion: DS-2 hover/active physics (translate ±2px, 0.1s ease) and plain CSS transitions. At most ONE prominent animated element per page (an animated number, hero or background). Under prefers-reduced-motion there is no motion at all. Already installed for this: @number-flow/react (course progress percent) and vaul (mobile drawer). Do NOT add motion, torph, Cobe or other animation libraries without the owner's explicit approval.
- Never install a new dependency without asking first: name the package, its size and why the existing stack can't do it.
- Already installed and preferred: sonner (src/components/ui/sonner.tsx), cmdk (src/components/admin/admin-command-menu.tsx), vaul via src/components/shared/responsive-dialog.tsx, @number-flow/react, @dnd-kit, clsx, class-variance-authority, next-themes, lucide-react, tailwind-merge.
+6
View File
@@ -21,6 +21,12 @@ TURNSTILE_SECRET_KEY=""
# (ym init snippet) ставится в admin → Settings → headCode, отдельно от этого ID.
NEXT_PUBLIC_METRIKA_COUNTER_ID=""
# Адрес payment-router для чекаута витрины (/pay/order, /pay/methods). Build-time.
# Пусто: dev → локальная заглушка /dev/pay-stub, production-сборка → боевой
# https://obsidian.second-brain.ru. Для локальной `next build && next start`
# задать явно (например http://localhost:9), иначе форма уйдёт на прод.
NEXT_PUBLIC_PAY_BASE_URL=""
# Kinescope API
KINESCOPE_API_KEY=""
+1
View File
@@ -220,6 +220,7 @@ S3_REGION="eu-central"
### Env
- **Чекаут витрины ходит в боевой payment-router из любой production-сборки.** Адрес `/pay/order` и `/pay/methods` выбирает `resolvePayBase` (`src/lib/pay-base.ts`): `NEXT_PUBLIC_PAY_BASE_URL`, иначе при `NODE_ENV=production` — `https://obsidian.second-brain.ru`, иначе — локальная заглушка `/dev/pay-stub` (в проде 404, ничего не сохраняет и не шлёт). `next dev` безопасен, а **локальная `next build && next start` без `NEXT_PUBLIC_PAY_BASE_URL` отправит форму на прод**: реальный заказ, pending в леджере, Telegram владельцу (случай 20261111, заказ 1794380127). Для такой сборки задавать переменную явно, например `http://localhost:9`. Браузерные проверки — только с сетевой блокировкой внешних доменов и без отправки форм.
- `NEXT_PUBLIC_*` инлайнятся **на сборке**. `NEXT_PUBLIC_APP_URL` задан только как runtime-env в `docker-compose.prod.yml`, в образ не попадает → работает захардкоженный фолбэк на прод-адрес. Следствие: на staging студенту выдаются Zotero-скрипт и curl-пример с **прод**-адресом. Правишь — передавай через `--build-arg`, как `NEXT_PUBLIC_TURNSTILE_SITE_KEY`.
### Тариф — это отдельный курс со своей копией уроков
+784 -3
View File
@@ -15,6 +15,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@kinescope/react-kinescope-player": "^0.5.4",
"@number-flow/react": "^0.6.2",
"@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0",
"@tiptap/extension-image": "^3.22.2",
@@ -28,6 +29,7 @@
"better-auth": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"defuddle": "^0.19.1",
"disposable-email-domains": "^1.0.62",
"force-graph": "^1.51.4",
@@ -49,6 +51,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"unified": "^11.0.5",
"vaul": "^1.1.2",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -2822,6 +2825,20 @@
"node": ">=12.4.0"
}
},
"node_modules/@number-flow/react": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@number-flow/react/-/react-0.6.2.tgz",
"integrity": "sha512-WjZuV4aA+vhRCgCF+adGLgFVVAJ8vdvq6EchRT2FiBIgElTXtDOA3YgkcMr9UHpvpS9v4H70AB5wZv0D9jc3QA==",
"license": "MIT",
"dependencies": {
"esm-env": "^1.1.4",
"number-flow": "0.6.2"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
@@ -3096,7 +3113,557 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz",
"integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz",
"integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.2.0",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.3.0",
"@radix-ui/react-dismissable-layer": "1.2.1",
"@radix-ui/react-focus-guards": "1.2.0",
"@radix-ui/react-focus-scope": "1.3.1",
"@radix-ui/react-id": "1.1.4",
"@radix-ui/react-portal": "1.2.0",
"@radix-ui/react-presence": "1.1.11",
"@radix-ui/react-primitive": "2.2.0",
"@radix-ui/react-slot": "1.5.0",
"@radix-ui/react-use-controllable-state": "1.3.0",
"@radix-ui/react-use-layout-effect": "1.1.4",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz",
"integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==",
"license": "MIT"
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz",
"integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.2.0",
"@radix-ui/react-use-effect-event": "0.0.5",
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz",
"integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.2.0",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-primitive": "2.2.0",
"@radix-ui/react-use-callback-ref": "1.1.5",
"@radix-ui/react-use-effect-event": "0.0.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/primitive": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz",
"integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==",
"license": "MIT"
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz",
"integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz",
"integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-primitive": "2.2.0",
"@radix-ui/react-use-callback-ref": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz",
"integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.2.0",
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz",
"integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
@@ -3112,7 +3679,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
@@ -3136,7 +3702,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
@@ -3177,6 +3742,39 @@
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz",
"integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
@@ -6051,6 +6649,18 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@@ -6831,6 +7441,22 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -7424,6 +8050,12 @@
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -8176,6 +8808,12 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT"
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -8706,6 +9344,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-port-please": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
@@ -11574,6 +12221,15 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/number-flow": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/number-flow/-/number-flow-0.6.2.tgz",
"integrity": "sha512-MCnImG4Q5vPwhSXnov56nOuyyKn6LC+Qd7II1UiKc+ACRtug5iAtn0+CwXNxM38AC5lSowEY+oYEtZX2qMnUyw==",
"license": "MIT",
"dependencies": {
"esm-env": "^1.1.4"
}
},
"node_modules/nypm": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
@@ -12586,6 +13242,75 @@
"react": ">=18"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -14243,6 +14968,49 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -14287,6 +15055,19 @@
}
}
},
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+3
View File
@@ -21,6 +21,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@kinescope/react-kinescope-player": "^0.5.4",
"@number-flow/react": "^0.6.2",
"@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0",
"@tiptap/extension-image": "^3.22.2",
@@ -34,6 +35,7 @@
"better-auth": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"defuddle": "^0.19.1",
"disposable-email-domains": "^1.0.62",
"force-graph": "^1.51.4",
@@ -55,6 +57,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"unified": "^11.0.5",
"vaul": "^1.1.2",
"zod": "^4.3.6"
},
"devDependencies": {
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// Read-only lookup for the admin command menu (Ctrl/⌘+K).
const MIN_QUERY = 2;
const MAX_QUERY = 100;
export async function GET(request: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Нет доступа" }, { status: 403 });
}
const q = (request.nextUrl.searchParams.get("q") ?? "").trim().slice(0, MAX_QUERY);
if (q.length < MIN_QUERY) {
return NextResponse.json({ users: [], courses: [], lessons: [] });
}
const contains = { contains: q, mode: "insensitive" as const };
const [users, courses, lessons] = await Promise.all([
prisma.user.findMany({
where: { OR: [{ name: contains }, { email: contains }] },
select: { id: true, name: true, email: true, role: true },
orderBy: { createdAt: "desc" },
take: 8,
}),
prisma.course.findMany({
where: { OR: [{ title: contains }, { slug: contains }] },
select: { id: true, title: true, slug: true, published: true },
orderBy: { order: "asc" },
take: 6,
}),
prisma.lesson.findMany({
where: { title: contains },
select: {
id: true,
title: true,
published: true,
module: { select: { id: true, courseId: true, course: { select: { title: true } } } },
},
orderBy: { updatedAt: "desc" },
take: 8,
}),
]);
return NextResponse.json({
users,
courses,
lessons: lessons.map((l) => ({
id: l.id,
title: l.title,
published: l.published,
moduleId: l.module.id,
courseId: l.module.courseId,
courseTitle: l.module.course.title,
})),
});
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
// Local stand-in for payment-router GET /pay/methods (see src/lib/pay-base.ts).
// Production: 404. No outbound calls.
export function GET() {
if (process.env.NODE_ENV === "production") {
return new NextResponse(null, { status: 404 });
}
return NextResponse.json({
methods: [
{ id: "stub-card", label: "Заглушка: карта", provider: "dev-stub", kind: "online" },
{ id: "stub-invoice", label: "Заглушка: счёт", provider: "dev-stub", kind: "manual" },
],
});
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
// Local stand-in for payment-router POST /pay/order (see src/lib/pay-base.ts).
// Echoes the submitted fields back as a page. Stores nothing, sends nothing.
// Production: 404.
function escapeHtml(s: string) {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string,
);
}
export async function POST(request: NextRequest) {
if (process.env.NODE_ENV === "production") {
return new NextResponse(null, { status: 404 });
}
const form = await request.formData();
const rows = [...form.entries()]
.map(([k, v]) => `<tr><td>${escapeHtml(k)}</td><td>${escapeHtml(typeof v === "string" ? v : "[файл]")}</td></tr>`)
.join("");
const html = `<!doctype html><meta charset="utf-8"><title>Заглушка оплаты</title>
<body style="font-family:'Fira Mono',ui-monospace,monospace;background:#F5F5F0;color:#323232;padding:24px">
<h1 style="font-size:20px">Заглушка оплаты (dev)</h1>
<p>Заказ не создан, данные никуда не отправлены. Так выглядела бы отправка на payment-router:</p>
<table style="border:2px solid #323232;border-collapse:collapse" cellpadding="6">${rows}</table>
<p><a href="/dashboard">← На дашборд</a></p></body>`;
return new NextResponse(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}
+11
View File
@@ -298,3 +298,14 @@
margin: 0 0 0.5rem;
}
.comment-md pre code { background: none; padding: 0; }
/* DS-2 §8: with prefers-reduced-motion there is no motion at all.
Vaul animates the sheet via injected CSS and inline transitions while
dragging — !important overrides both, the sheet just appears/disappears. */
@media (prefers-reduced-motion: reduce) {
[data-vaul-drawer],
[data-vaul-overlay] {
animation: none !important;
transition: none !important;
}
}
+277
View File
@@ -0,0 +1,277 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
import { Command } from "cmdk";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { Search } from "lucide-react";
import { adminLinks } from "@/components/admin/admin-nav";
interface SearchResults {
users: { id: string; name: string; email: string; role: string }[];
courses: { id: string; title: string; slug: string; published: boolean }[];
lessons: {
id: string;
title: string;
published: boolean;
moduleId: string;
courseId: string;
courseTitle: string;
}[];
}
const EMPTY: SearchResults = { users: [], courses: [], lessons: [] };
const MIN_QUERY = 2;
const DEBOUNCE_MS = 200;
const ROLE_LABEL: Record<string, string> = {
student: "студент",
curator: "куратор",
admin: "админ",
};
const noopSubscribe = () => () => {};
function useShortcutLabel() {
return useSyncExternalStore(
noopSubscribe,
() => (/Mac|iPhone|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl K"),
() => "Ctrl K",
);
}
// DS-2: hard-edged group headings, accent highlight for the active row.
const groupClass =
"mb-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pt-3 [&_[cmdk-group-heading]]:pb-1 " +
"[&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-bold [&_[cmdk-group-heading]]:uppercase " +
"[&_[cmdk-group-heading]]:tracking-widest [&_[cmdk-group-heading]]:text-[var(--muted-foreground)]";
const itemClass =
"flex min-h-[44px] cursor-pointer items-center gap-3 border-2 border-transparent px-2 py-1.5 text-sm " +
"data-[selected=true]:border-[var(--foreground)] data-[selected=true]:bg-[var(--accent)]";
export function AdminCommandMenu({ onOpen }: { onOpen?: () => void }) {
const router = useRouter();
const shortcut = useShortcutLabel();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResults>(EMPTY);
const [loading, setLoading] = useState(false);
// Global hotkey: Ctrl+K / ⌘K toggles the menu from anywhere in the admin.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((v) => !v);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// Debounced server lookup; stale responses are aborted.
const trimmed = query.trim();
useEffect(() => {
if (trimmed.length < MIN_QUERY) return;
const controller = new AbortController();
const timer = setTimeout(() => {
setLoading(true);
fetch(`/api/admin/search?q=${encodeURIComponent(trimmed)}`, { signal: controller.signal })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
.then((data: SearchResults) => setResults(data))
.catch((e: unknown) => {
if ((e as Error).name !== "AbortError") {
console.error("admin search failed", e);
setResults(EMPTY);
}
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
}, DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [trimmed]);
const active = trimmed.length >= MIN_QUERY;
const shown = active ? results : EMPTY;
const showLoading = active && loading;
const needle = trimmed.toLowerCase();
const sections = needle
? adminLinks.filter((l) => l.label.toLowerCase().includes(needle))
: adminLinks;
function go(href: string) {
setOpen(false);
setQuery("");
setResults(EMPTY);
router.push(href);
}
return (
<>
<button
type="button"
onClick={() => {
onOpen?.();
setOpen(true);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors hover:bg-[var(--sidebar-surface)] hover:text-[#F5F5F0]"
style={{ border: "2px solid var(--sidebar-border)", color: "var(--sidebar-text)" }}
aria-label="Поиск по админке"
>
<Search size={14} aria-hidden />
<span className="flex-1 text-left">Поиск</span>
<kbd className="text-[0.65rem] tracking-widest" style={{ fontFamily: "inherit" }}>
{shortcut}
</kbd>
</button>
<DialogPrimitive.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setQuery("");
}}
>
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop
className="fixed inset-0 z-50"
style={{ background: "rgba(50,50,50,0.45)" }}
/>
<DialogPrimitive.Popup
className="fixed top-[12vh] left-1/2 z-50 w-[calc(100%-2rem)] max-w-xl -translate-x-1/2 outline-none"
style={{
background: "var(--background)",
color: "var(--foreground)",
border: "2px solid var(--foreground)",
boxShadow: "6px 6px 0 0 var(--foreground)",
}}
>
<DialogPrimitive.Title className="sr-only">Поиск по админке</DialogPrimitive.Title>
<Command
shouldFilter={false}
label="Поиск по админке"
loop
// The input is the only tabbable element here, and Base UI's focus
// guards let Tab leak out of a single-element trap (seen live:
// focus walked to the sidebar while the menu stayed open). In a
// command menu arrows move the selection, so Tab simply stays put.
onKeyDown={(e) => {
if (e.key === "Tab") e.preventDefault();
}}
>
<div
className="flex items-center gap-2 px-3"
style={{ borderBottom: "2px solid var(--border)" }}
>
<Search size={16} aria-hidden style={{ color: "var(--muted-foreground)" }} />
<Command.Input
value={query}
onValueChange={setQuery}
placeholder="Студент, курс, урок или раздел…"
className="h-12 flex-1 bg-transparent text-sm outline-none placeholder:text-[var(--muted-foreground)]"
/>
{showLoading && (
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
ищем…
</span>
)}
</div>
<Command.List className="max-h-[60vh] overflow-y-auto p-2">
<Command.Empty className="px-2 py-6 text-center text-sm" style={{ color: "var(--muted-foreground)" }}>
{!active ? "Введите хотя бы два символа" : showLoading ? "Ищем…" : "Ничего не нашлось"}
</Command.Empty>
{shown.users.length > 0 && (
<Command.Group heading="Студенты и сотрудники" className={groupClass}>
{shown.users.map((u) => (
<Command.Item
key={u.id}
value={`user:${u.id}`}
onSelect={() => go(`/admin/users/${u.id}`)}
className={itemClass}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{u.name}</span>
<span className="block truncate text-xs" style={{ color: "var(--muted-foreground)" }}>
{u.email}
</span>
</span>
{u.role !== "student" && (
<span className="tag-aubade">{ROLE_LABEL[u.role] ?? u.role}</span>
)}
</Command.Item>
))}
</Command.Group>
)}
{shown.courses.length > 0 && (
<Command.Group heading="Курсы" className={groupClass}>
{shown.courses.map((c) => (
<Command.Item
key={c.id}
value={`course:${c.id}`}
onSelect={() => go(`/admin/courses/${c.id}`)}
className={itemClass}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{c.title}</span>
<span className="block truncate text-xs" style={{ color: "var(--muted-foreground)" }}>
{c.slug}
</span>
</span>
{!c.published && <span className="tag-aubade">черновик</span>}
</Command.Item>
))}
</Command.Group>
)}
{shown.lessons.length > 0 && (
<Command.Group heading="Уроки" className={groupClass}>
{shown.lessons.map((l) => (
<Command.Item
key={l.id}
value={`lesson:${l.id}`}
onSelect={() =>
go(`/admin/courses/${l.courseId}/modules/${l.moduleId}/lessons/${l.id}`)
}
className={itemClass}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{l.title}</span>
<span className="block truncate text-xs" style={{ color: "var(--muted-foreground)" }}>
{l.courseTitle}
</span>
</span>
{!l.published && <span className="tag-aubade">черновик</span>}
</Command.Item>
))}
</Command.Group>
)}
{sections.length > 0 && (
<Command.Group heading="Разделы" className={groupClass}>
{sections.map((l) => (
<Command.Item
key={l.href}
value={`section:${l.href}`}
onSelect={() => go(l.href)}
className={itemClass}
>
{l.label}
</Command.Item>
))}
</Command.Group>
)}
</Command.List>
</Command>
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</>
);
}
+2 -2
View File
@@ -3,7 +3,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
const links = [
export const adminLinks = [
{ href: "/admin/dashboard", label: "Обзор" },
{ href: "/admin/courses", label: "Курсы" },
{ href: "/admin/categories", label: "Категории" },
@@ -21,7 +21,7 @@ export function AdminNav({ questionsBadge = 0 }: { questionsBadge?: number }) {
return (
<>
{links.map(({ href, label }) => {
{adminLinks.map(({ href, label }) => {
const active =
pathname === href ||
(href !== "/admin/dashboard" && href !== "/curator/homework" && pathname.startsWith(href)) ||
+4
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { Menu, X } from "lucide-react";
import { AdminNav } from "@/components/admin/admin-nav";
import { LogoutButton } from "@/components/layout/logout-button";
import { AdminCommandMenu } from "@/components/admin/admin-command-menu";
export function AdminShell({
children,
@@ -56,6 +57,9 @@ export function AdminShell({
Администратор
</p>
</div>
<div className="px-2 pt-4">
<AdminCommandMenu onOpen={() => setOpen(false)} />
</div>
{/* Закрываем меню при переходе по ссылке на мобильном */}
<nav className="flex-1 px-2 py-4 space-y-0.5 overflow-y-auto" onClick={() => setOpen(false)}>
<AdminNav questionsBadge={questionsBadge} />
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { createContext, useContext, useRef, type ReactNode } from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { Drawer as Vaul } from "vaul";
import { useIsMobile } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
// DS-2 Aubade overlay: graphite at 45%, no blur.
const BACKDROP = "rgba(50,50,50,0.45)";
const MobileContext = createContext(false);
interface DrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Fires once the close animation has finished (safe moment to unmount). */
onClosed?: () => void;
children: ReactNode;
className?: string;
}
/**
* Bottom sheet for phones (Vaul), dressed in DS-2: 2px graphite top border,
* 2px corners, a flat rectangular grab handle (no pill shapes in the system).
*/
export function AubadeDrawer({ open, onOpenChange, onClosed, children, className }: DrawerProps) {
return (
<MobileContext.Provider value={true}>
<Vaul.Root
open={open}
onOpenChange={onOpenChange}
onAnimationEnd={(isOpen) => {
if (!isOpen) onClosed?.();
}}
>
<Vaul.Portal>
<Vaul.Overlay className="fixed inset-0 z-50" style={{ background: BACKDROP }} />
<Vaul.Content
aria-describedby={undefined}
className="fixed inset-x-0 bottom-0 z-50 flex max-h-[90dvh] flex-col rounded-t-[2px] outline-none"
style={{
background: "var(--background)",
color: "var(--foreground)",
borderTop: "2px solid var(--foreground)",
}}
>
<div
aria-hidden
className="mx-auto mt-3 mb-1 h-1.5 w-12 shrink-0"
style={{ background: "var(--border)" }}
/>
<div
className={cn(
"overflow-y-auto px-5 pt-2 pb-[max(1.25rem,env(safe-area-inset-bottom))]",
className,
)}
>
{children}
</div>
</Vaul.Content>
</Vaul.Portal>
</Vaul.Root>
</MobileContext.Provider>
);
}
/**
* Centered modal on desktop (Base UI Dialog), bottom sheet on phones (<768px).
* Put a <ResponsiveDialogTitle> inside — it renders the right accessible title
* for whichever primitive is active.
*/
export function ResponsiveDialog({ open, onOpenChange, onClosed, children, className }: DrawerProps) {
const isMobile = useIsMobile();
const popupRef = useRef<HTMLDivElement>(null);
if (isMobile) {
return (
<AubadeDrawer open={open} onOpenChange={onOpenChange} onClosed={onClosed} className={className}>
{children}
</AubadeDrawer>
);
}
return (
<DialogPrimitive.Root
open={open}
onOpenChange={(next) => {
onOpenChange(next);
// The desktop modal has no exit animation, so it is safe to unmount now.
if (!next) onClosed?.();
}}
>
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop className="fixed inset-0 z-50" style={{ background: BACKDROP }} />
<DialogPrimitive.Popup
ref={popupRef}
initialFocus={popupRef}
className={cn(
"fixed top-1/2 left-1/2 z-50 w-[calc(100%-2rem)] max-w-md max-h-[90dvh] -translate-x-1/2 -translate-y-1/2 overflow-y-auto p-6 outline-none",
className,
)}
style={{
background: "var(--background)",
color: "var(--foreground)",
border: "2px solid var(--foreground)",
boxShadow: "6px 6px 0 0 var(--foreground)",
}}
>
{children}
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
export function ResponsiveDialogTitle({ children, className }: { children: ReactNode; className?: string }) {
const isMobile = useContext(MobileContext);
return isMobile ? (
<Vaul.Title className={className}>{children}</Vaul.Title>
) : (
<DialogPrimitive.Title className={className}>{children}</DialogPrimitive.Title>
);
}
/** Close control that works inside both variants. */
export function ResponsiveDialogClose({
children,
className,
style,
"aria-label": ariaLabel,
}: {
children: ReactNode;
className?: string;
style?: React.CSSProperties;
"aria-label"?: string;
}) {
const isMobile = useContext(MobileContext);
return isMobile ? (
<Vaul.Close type="button" className={className} style={style} aria-label={ariaLabel}>
{children}
</Vaul.Close>
) : (
<DialogPrimitive.Close type="button" className={className} style={style} aria-label={ariaLabel}>
{children}
</DialogPrimitive.Close>
);
}
+101 -45
View File
@@ -4,6 +4,9 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { Menu, X } from "lucide-react";
import NumberFlow from "@number-flow/react";
import { AubadeDrawer, ResponsiveDialogTitle } from "@/components/shared/responsive-dialog";
import { useIsMobile } from "@/hooks/use-media-query";
interface Lesson {
id: string;
@@ -22,16 +25,20 @@ interface Course {
modules: Module[];
}
export function CourseSidebar({
function CourseNavBody({
course,
completedLessonIds = new Set(),
completedLessonIds,
pathname,
onNavigate,
animateProgress,
}: {
course: Course;
completedLessonIds?: Set<string>;
completedLessonIds: Set<string>;
pathname: string;
onNavigate: () => void;
/** DS-2: one animated moment per page — only the visible copy animates. */
animateProgress: boolean;
}) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const totalLessons = course.modules.reduce((s, m) => s + m.lessons.length, 0);
const completedCount = course.modules
.flatMap((m) => m.lessons)
@@ -40,46 +47,11 @@ export function CourseSidebar({
return (
<>
{/* Mobile hamburger */}
<button
className="lg:hidden fixed top-3 left-3 z-50 p-2 rounded"
style={{
backgroundColor: "var(--background)",
border: "1.5px solid var(--border)",
color: "var(--foreground)",
}}
onClick={() => setOpen((v) => !v)}
aria-label="Навигация по курсу"
>
{open ? <X size={20} /> : <Menu size={20} />}
</button>
{/* Overlay */}
{open && (
<div
className="lg:hidden fixed inset-0 bg-black/50 z-30"
onClick={() => setOpen(false)}
/>
)}
<aside
className={[
"w-64 flex flex-col overflow-y-auto",
"fixed top-[53px] left-0 bottom-0 z-40 transition-transform duration-200",
"lg:sticky lg:shrink-0 lg:translate-x-0",
open ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
].join(" ")}
style={{
borderRight: "2px solid var(--border)",
backgroundColor: "var(--background)",
maxHeight: "calc(100vh - 53px)",
top: "53px",
}}
>
{/* Course title + progress */}
<div className="px-4 py-4" style={{ borderBottom: "2px solid var(--border)" }}>
<Link
href={`/courses/${course.slug}`}
onClick={onNavigate}
className="font-bold text-sm leading-snug block mb-1"
style={{ color: "var(--foreground)" }}
>
@@ -87,6 +59,7 @@ export function CourseSidebar({
</Link>
<Link
href="/dashboard"
onClick={onNavigate}
className="text-xs block underline mb-3"
style={{ color: "var(--muted-foreground)" }}
>
@@ -100,7 +73,13 @@ export function CourseSidebar({
{completedCount} из {totalLessons} уроков
</span>
<span className="text-xs font-bold" style={{ color: "var(--foreground)" }}>
{progressPct}%
{/* The page's single animated moment (DS-2 §8); static under reduced motion. */}
<NumberFlow
value={progressPct}
suffix="%"
animated={animateProgress}
respectMotionPreference
/>
</span>
</div>
<div
@@ -108,7 +87,7 @@ export function CourseSidebar({
style={{ background: "var(--border)" }}
>
<div
className="h-full transition-all duration-300"
className="h-full transition-all duration-300 motion-reduce:transition-none"
style={{
width: `${progressPct}%`,
background: progressPct === 100 ? "var(--foreground)" : "var(--accent)",
@@ -146,7 +125,7 @@ export function CourseSidebar({
<Link
key={lesson.id}
href={`/courses/${course.slug}/lessons/${lesson.id}`}
onClick={() => setOpen(false)}
onClick={() => onNavigate()}
className="flex items-center gap-2 px-4 py-2 text-sm leading-snug border-l-2 transition-colors"
style={{
borderLeftColor: active ? "var(--foreground)" : "transparent",
@@ -173,7 +152,84 @@ export function CourseSidebar({
);
})}
</nav>
</>
);
}
export function CourseSidebar({
course,
completedLessonIds = new Set(),
}: {
course: Course;
completedLessonIds?: Set<string>;
}) {
const pathname = usePathname();
// Tablet (768–1023px): the old off-canvas panel. Phone (<768px): bottom sheet.
const [open, setOpen] = useState(false);
const [sheetOpen, setSheetOpen] = useState(false);
const isMobile = useIsMobile();
const menuOpen = isMobile ? sheetOpen : open;
return (
<>
{/* Mobile hamburger */}
<button
className="lg:hidden fixed top-3 left-3 z-50 p-2 rounded"
style={{
backgroundColor: "var(--background)",
border: "1.5px solid var(--border)",
color: "var(--foreground)",
}}
onClick={() => (isMobile ? setSheetOpen((v) => !v) : setOpen((v) => !v))}
aria-label="Навигация по курсу"
aria-expanded={menuOpen}
>
{menuOpen ? <X size={20} /> : <Menu size={20} />}
</button>
{/* Overlay */}
{open && !isMobile && (
<div
className="lg:hidden fixed inset-0 bg-black/50 z-30"
onClick={() => setOpen(false)}
/>
)}
<aside
className={[
"w-64 flex flex-col overflow-y-auto",
"fixed top-[53px] left-0 bottom-0 z-40 transition-transform duration-200",
"lg:sticky lg:shrink-0 lg:translate-x-0",
open && !isMobile ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
].join(" ")}
style={{
borderRight: "2px solid var(--border)",
backgroundColor: "var(--background)",
maxHeight: "calc(100vh - 53px)",
top: "53px",
}}
>
<CourseNavBody
course={course}
completedLessonIds={completedLessonIds}
pathname={pathname}
onNavigate={() => setOpen(false)}
animateProgress={!isMobile}
/>
</aside>
{isMobile && (
<AubadeDrawer open={sheetOpen} onOpenChange={setSheetOpen} className="px-0 pt-0">
<ResponsiveDialogTitle className="sr-only">Навигация по курсу</ResponsiveDialogTitle>
<CourseNavBody
course={course}
completedLessonIds={completedLessonIds}
pathname={pathname}
onNavigate={() => setSheetOpen(false)}
animateProgress
/>
</AubadeDrawer>
)}
</>
);
}
+15 -74
View File
@@ -1,11 +1,16 @@
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
import { useEffect, useId, useState, type CSSProperties } from "react";
import {
PAY_METHODS_URL,
PAY_ORDER_URL,
type StoreCatalogEntry,
} from "@/lib/store-catalog";
import {
ResponsiveDialog,
ResponsiveDialogClose,
ResponsiveDialogTitle,
} from "@/components/shared/responsive-dialog";
export interface LockedCourse {
id: string;
@@ -50,7 +55,9 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
const [method, setMethod] = useState("robokassa");
const [otherOpen, setOtherOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const dialogRef = useRef<HTMLDivElement>(null);
// Kept open locally so the phone sheet can finish its close animation
// before the parent unmounts us via onClose.
const [open, setOpen] = useState(true);
const titleId = useId();
const multiTariff = entry.tariffs.length > 1;
@@ -71,31 +78,6 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
.catch(() => {});
}, []);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
// Lock body scroll while the modal is open
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
// Focus the dialog on open, restore focus on close
useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
dialogRef.current?.focus();
return () => prev?.focus();
}, []);
// Coming back from the payment page via bfcache must not leave the
// submit button stuck in the "submitting" state.
useEffect(() => {
@@ -106,50 +88,12 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
return () => window.removeEventListener("pageshow", onPageShow);
}, []);
// Minimal focus trap: keep Tab cycling inside the dialog
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key !== "Tab" || !dialogRef.current) return;
const focusables = dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, [tabindex]:not([tabindex="-1"])'
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
const labelClass = "block text-xs font-bold uppercase tracking-widest mb-1.5";
const primary = methods?.[0];
const rest = methods?.slice(1) ?? [];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center px-4"
style={{ background: "rgba(50,50,50,0.45)" }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
onKeyDown={handleKeyDown}
className="w-full max-w-md max-h-[90dvh] overflow-y-auto p-6 space-y-4"
style={{
background: "var(--background)",
border: "2px solid var(--foreground)",
boxShadow: "6px 6px 0 0 var(--foreground)",
}}
>
<ResponsiveDialog open={open} onOpenChange={setOpen} onClosed={onClose} className="space-y-4">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<p
@@ -158,15 +102,13 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
>
Покупка курса
</p>
<button
type="button"
onClick={onClose}
<ResponsiveDialogClose
aria-label="Закрыть"
className="-mt-3 -mr-3 flex h-11 w-11 items-center justify-center"
style={{ color: "var(--muted-foreground)", fontSize: "22px", lineHeight: 1 }}
>
×
</button>
</ResponsiveDialogClose>
</div>
<div className="flex items-start gap-3">
@@ -186,9 +128,9 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
📚
</div>
)}
<h2 id={titleId} className="font-bold text-lg leading-tight">
<ResponsiveDialogTitle className="font-bold text-lg leading-tight">
{course.title}
</h2>
</ResponsiveDialogTitle>
</div>
{course.description && (
@@ -366,7 +308,6 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
после оплаты.
</p>
</div>
</div>
</div>
</ResponsiveDialog>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
/** Design-system breakpoint (DS-2 Aubade): below 768px is the mobile layout. */
export const MOBILE_QUERY = "(max-width: 767px)";
/**
* Subscribes to a CSS media query. On the server and during hydration it
* returns `false`, so markup rendered on the server is the desktop variant and
* the mobile one takes over right after hydration.
*/
export function useMediaQuery(query: string): boolean {
const subscribe = useCallback(
(onChange: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
},
[query],
);
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
() => false,
);
}
export function useIsMobile(): boolean {
return useMediaQuery(MOBILE_QUERY);
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { DEV_PAY_STUB_BASE, LIVE_PAY_BASE, resolvePayBase } from "../pay-base";
describe("resolvePayBase", () => {
it("dev без переменной — локальная заглушка", () => {
expect(resolvePayBase({ nodeEnv: "development" })).toBe(DEV_PAY_STUB_BASE);
expect(resolvePayBase({ nodeEnv: "test" })).toBe(DEV_PAY_STUB_BASE);
expect(resolvePayBase({})).toBe(DEV_PAY_STUB_BASE);
});
it("production без переменной — боевой роутер", () => {
expect(resolvePayBase({ nodeEnv: "production" })).toBe(LIVE_PAY_BASE);
expect(resolvePayBase({ nodeEnv: "production", payBaseUrl: " " })).toBe(LIVE_PAY_BASE);
});
it("явная переменная побеждает в любом режиме", () => {
expect(resolvePayBase({ nodeEnv: "production", payBaseUrl: "http://localhost:9" })).toBe("http://localhost:9");
expect(resolvePayBase({ nodeEnv: "development", payBaseUrl: "https://pay.example/" })).toBe("https://pay.example");
});
});
+16
View File
@@ -0,0 +1,16 @@
// Where the storefront checkout talks to: the payment router's /pay/order and
// /pay/methods. Production builds default to the live router; everything else
// (next dev, tests) defaults to the local stub so a stray click can't create a
// real order. An explicit NEXT_PUBLIC_PAY_BASE_URL always wins.
//
// ⚠️ NODE_ENV is "production" for a local `next build && next start` too —
// such a build hits the live router unless NEXT_PUBLIC_PAY_BASE_URL is set.
export const LIVE_PAY_BASE = "https://obsidian.second-brain.ru";
export const DEV_PAY_STUB_BASE = "/dev/pay-stub";
export function resolvePayBase(env: { payBaseUrl?: string; nodeEnv?: string }): string {
const explicit = env.payBaseUrl?.trim();
if (explicit) return explicit.replace(/\/+$/, "");
return env.nodeEnv === "production" ? LIVE_PAY_BASE : DEV_PAY_STUB_BASE;
}
+9 -2
View File
@@ -5,6 +5,8 @@
// price here is a display bug, not a money bug. When a price changes:
// products.yaml → landing → this file.
import { resolvePayBase } from "@/lib/pay-base";
export interface StoreTariff {
sku: string; // must match payment-router/products.yaml
label: string;
@@ -17,8 +19,13 @@ export interface StoreCatalogEntry {
tariffs: StoreTariff[];
}
export const PAY_ORDER_URL = "https://obsidian.second-brain.ru/pay/order";
export const PAY_METHODS_URL = "https://obsidian.second-brain.ru/pay/methods";
// Literal process.env.* reads so Next inlines them into the client bundle.
const PAY_BASE = resolvePayBase({
payBaseUrl: process.env.NEXT_PUBLIC_PAY_BASE_URL,
nodeEnv: process.env.NODE_ENV,
});
export const PAY_ORDER_URL = `${PAY_BASE}/pay/order`;
export const PAY_METHODS_URL = `${PAY_BASE}/pay/methods`;
// Keys = course slugs shown as locked cards on the student dashboard.
// Only tariffs of the course's "native" landing are listed — cross-bundles