Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62d76916ca | ||
|
|
ba6da38b9c | ||
|
|
7a231cf92f | ||
|
|
197bbb80ec | ||
|
|
09b069cbb5 |
@@ -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.
|
||||
@@ -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=""
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
### Тариф — это отдельный курс со своей копией уроков
|
||||
|
||||
Generated
+784
-3
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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" } });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +25,137 @@ interface Course {
|
||||
modules: Module[];
|
||||
}
|
||||
|
||||
function CourseNavBody({
|
||||
course,
|
||||
completedLessonIds,
|
||||
pathname,
|
||||
onNavigate,
|
||||
animateProgress,
|
||||
}: {
|
||||
course: Course;
|
||||
completedLessonIds: Set<string>;
|
||||
pathname: string;
|
||||
onNavigate: () => void;
|
||||
/** DS-2: one animated moment per page — only the visible copy animates. */
|
||||
animateProgress: boolean;
|
||||
}) {
|
||||
const totalLessons = course.modules.reduce((s, m) => s + m.lessons.length, 0);
|
||||
const completedCount = course.modules
|
||||
.flatMap((m) => m.lessons)
|
||||
.filter((l) => completedLessonIds.has(l.id)).length;
|
||||
const progressPct = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 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)" }}
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
onClick={onNavigate}
|
||||
className="text-xs block underline mb-3"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
← Все курсы
|
||||
</Link>
|
||||
|
||||
{totalLessons > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{completedCount} из {totalLessons} уроков
|
||||
</span>
|
||||
<span className="text-xs font-bold" style={{ color: "var(--foreground)" }}>
|
||||
{/* The page's single animated moment (DS-2 §8); static under reduced motion. */}
|
||||
<NumberFlow
|
||||
value={progressPct}
|
||||
suffix="%"
|
||||
animated={animateProgress}
|
||||
respectMotionPreference
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-1.5 w-full"
|
||||
style={{ background: "var(--border)" }}
|
||||
>
|
||||
<div
|
||||
className="h-full transition-all duration-300 motion-reduce:transition-none"
|
||||
style={{
|
||||
width: `${progressPct}%`,
|
||||
background: progressPct === 100 ? "var(--foreground)" : "var(--accent)",
|
||||
border: progressPct > 0 ? "1px solid var(--foreground)" : "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modules and lessons */}
|
||||
<nav className="flex-1 py-2">
|
||||
{course.modules.map((mod) => {
|
||||
const modCompleted = mod.lessons.filter((l) => completedLessonIds.has(l.id)).length;
|
||||
return (
|
||||
<div key={mod.id}>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<p
|
||||
className="text-xs font-bold uppercase tracking-widest"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
{mod.title}
|
||||
</p>
|
||||
{mod.lessons.length > 0 && (
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{modCompleted}/{mod.lessons.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{mod.lessons.map((lesson) => {
|
||||
const active = pathname.includes(lesson.id);
|
||||
const done = completedLessonIds.has(lesson.id);
|
||||
return (
|
||||
<Link
|
||||
key={lesson.id}
|
||||
href={`/courses/${course.slug}/lessons/${lesson.id}`}
|
||||
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",
|
||||
backgroundColor: active ? "var(--color-highlight)" : "transparent",
|
||||
fontWeight: active ? 600 : 400,
|
||||
color: done && !active ? "var(--muted-foreground)" : "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 w-4 h-4 flex items-center justify-center text-xs"
|
||||
style={{
|
||||
border: `1.5px solid ${done ? "var(--foreground)" : "var(--border)"}`,
|
||||
background: done ? "var(--foreground)" : "transparent",
|
||||
color: "var(--background)",
|
||||
}}
|
||||
>
|
||||
{done && "✓"}
|
||||
</span>
|
||||
<span className="flex-1 leading-snug">{lesson.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CourseSidebar({
|
||||
course,
|
||||
completedLessonIds = new Set(),
|
||||
@@ -30,13 +164,11 @@ export function CourseSidebar({
|
||||
completedLessonIds?: Set<string>;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
// Tablet (768–1023px): the old off-canvas panel. Phone (<768px): bottom sheet.
|
||||
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)
|
||||
.filter((l) => completedLessonIds.has(l.id)).length;
|
||||
const progressPct = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0;
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const menuOpen = isMobile ? sheetOpen : open;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -48,14 +180,15 @@ export function CourseSidebar({
|
||||
border: "1.5px solid var(--border)",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={() => (isMobile ? setSheetOpen((v) => !v) : setOpen((v) => !v))}
|
||||
aria-label="Навигация по курсу"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
{open ? <X size={20} /> : <Menu size={20} />}
|
||||
{menuOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Overlay */}
|
||||
{open && (
|
||||
{open && !isMobile && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black/50 z-30"
|
||||
onClick={() => setOpen(false)}
|
||||
@@ -67,7 +200,7 @@ export function CourseSidebar({
|
||||
"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",
|
||||
open && !isMobile ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
|
||||
].join(" ")}
|
||||
style={{
|
||||
borderRight: "2px solid var(--border)",
|
||||
@@ -76,104 +209,27 @@ export function CourseSidebar({
|
||||
top: "53px",
|
||||
}}
|
||||
>
|
||||
{/* Course title + progress */}
|
||||
<div className="px-4 py-4" style={{ borderBottom: "2px solid var(--border)" }}>
|
||||
<Link
|
||||
href={`/courses/${course.slug}`}
|
||||
className="font-bold text-sm leading-snug block mb-1"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-xs block underline mb-3"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
← Все курсы
|
||||
</Link>
|
||||
|
||||
{totalLessons > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{completedCount} из {totalLessons} уроков
|
||||
</span>
|
||||
<span className="text-xs font-bold" style={{ color: "var(--foreground)" }}>
|
||||
{progressPct}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-1.5 w-full"
|
||||
style={{ background: "var(--border)" }}
|
||||
>
|
||||
<div
|
||||
className="h-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${progressPct}%`,
|
||||
background: progressPct === 100 ? "var(--foreground)" : "var(--accent)",
|
||||
border: progressPct > 0 ? "1px solid var(--foreground)" : "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modules and lessons */}
|
||||
<nav className="flex-1 py-2">
|
||||
{course.modules.map((mod) => {
|
||||
const modCompleted = mod.lessons.filter((l) => completedLessonIds.has(l.id)).length;
|
||||
return (
|
||||
<div key={mod.id}>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<p
|
||||
className="text-xs font-bold uppercase tracking-widest"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
{mod.title}
|
||||
</p>
|
||||
{mod.lessons.length > 0 && (
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{modCompleted}/{mod.lessons.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{mod.lessons.map((lesson) => {
|
||||
const active = pathname.includes(lesson.id);
|
||||
const done = completedLessonIds.has(lesson.id);
|
||||
return (
|
||||
<Link
|
||||
key={lesson.id}
|
||||
href={`/courses/${course.slug}/lessons/${lesson.id}`}
|
||||
onClick={() => setOpen(false)}
|
||||
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",
|
||||
backgroundColor: active ? "var(--color-highlight)" : "transparent",
|
||||
fontWeight: active ? 600 : 400,
|
||||
color: done && !active ? "var(--muted-foreground)" : "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 w-4 h-4 flex items-center justify-center text-xs"
|
||||
style={{
|
||||
border: `1.5px solid ${done ? "var(--foreground)" : "var(--border)"}`,
|
||||
background: done ? "var(--foreground)" : "transparent",
|
||||
color: "var(--background)",
|
||||
}}
|
||||
>
|
||||
{done && "✓"}
|
||||
</span>
|
||||
<span className="flex-1 leading-snug">{lesson.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user