Compare commits

...
8 Commits
Author SHA1 Message Date
Igor Martyniuk 0263164a58 Merge branch 'feature/TASKS-1827' into 'main'
Resolve TASKS-1827

Closes TASKS-1827

See merge request crux/admin!32
2026-09-02 09:22:34 +00:00
Igor Martyniuk 1b6840f654 Resolve TASKS-1827 2026-09-02 09:22:34 +00:00
Igor Martyniuk aae77ebc27 docs: update changelog for v1.3.1 2026-08-31 15:43:21 +03:00
Igor Martyniuk 3556662ddd Merge branch 'fix/misc-fixes' into 'main'
TASKS-1832

See merge request crux/admin!31
2026-08-31 12:38:14 +00:00
Igor Martyniuk ae34b30e4f TASKS-1832 2026-08-31 12:38:14 +00:00
Igor Martyniuk 93fc6ff5a0 docs: update changelog for v1.3.0 2026-08-28 11:43:02 +03:00
Igor Martyniuk e8558c076e Merge branch 'feature/page-manager' into 'main'
TASKS-1823 WebManager module

See merge request crux/admin!30
2026-08-28 08:35:58 +00:00
Igor Martyniuk c93d8c025b TASKS-1823 WebManager module 2026-08-28 08:35:58 +00:00
783 changed files with 28212 additions and 11935 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules node_modules
.next
npm-debug.log npm-debug.log
.git .git
+2 -2
View File
@@ -26,7 +26,7 @@ yarn-error.log
# Platform specific # Platform specific
.DS_Store .DS_Store
*.tsbuildinfo
/apps/backend/public/ /apps/backend/public/
/apps/backend/storage/ /apps/backend/storage/
/apps/backend/.adonisjs/
/apps/backend/database/schema.ts
+2
View File
@@ -1 +1,3 @@
npm run test:admin npm run test:admin
npm run test:backend
npm run test:web
+6 -3
View File
@@ -1,10 +1,13 @@
# Dependencies and generated output
**/node_modules/** **/node_modules/**
**/build/** **/build/**
**/dist/** **/dist/**
**/coverage/** **/coverage/**
**/.next/**
apps/backend/public/** apps/backend/public/**
apps/backend/storage/** apps/backend/storage/**
# Handlebars generator templates contain application syntax that is not valid HTML.
apps/backend/app/templates/** apps/backend/app/templates/**
apps/backend/.adonisjs/**
apps/backend/database/schema.ts
apps/web/.next/**
+76 -5
View File
@@ -9,17 +9,88 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added ### Added
- Added `@crux-kit/editor-tiptap`, a free, headless rich-text editor package built on TipTap. The `light` preset covers bold, italic, underline,
strikethrough, headings, lists, blockquote, text alignment, links, and undo/redo, reading and
writing content as plain HTML so it themes with the shared design-system tokens.
- Added the `InputTiptapEditor` composed form field.
### Changed ### Changed
- Rebuilt the nine non-default layouts against the application's own data.
### Fixed ### Fixed
### Removed ### Removed
## [1.3.1] - 2026-08-31
### Fixed
- Fixed Web Studio site settings rejecting empty optional fields. The backend bodyparser converts
empty strings to `null`, so a cleared menu title, footer copyright or legal path failed
validation; those fields are now optional in the vine schema (stored without the key) and the
admin maps them back to empty strings via `SiteSettingsMapper`.
- Backend validation errors on the settings page no longer disappear silently: the shared 422
handler skips them expecting the caller to map field errors, which the page never did. Structured
field errors are now set on the matching form inputs, the tab holding the first error is opened,
and message-only 422s surface as a toast.
- Fixed renaming or deleting a folder destroying sibling folders whose name shares its prefix
(renaming `aa` overwrote and then deleted `aab`). Drive strips the trailing slash off a listing
prefix, so a directory's contents are now filtered on the real `<path>/` prefix, and the S3
service deletes the listed keys instead of passing that prefix to `deleteAll`.
- Fixed the web container running against the host's `node_modules`: the `web` service keeps
`apps/web/node_modules` in an anonymous volume, so the bind mount no longer shadows the
dependencies installed in the image.
## [1.3.0] - 2026-08-28
### Added
- Added an `ApiException` base class whose self-handling renders module exceptions as
`{ message, code }` JSON responses, so exception classes no longer need registration in the
global HTTP exception handler.
- Added `@crux-kit/editor-tiptap`, a free, headless rich-text editor package built on TipTap. The `light` preset covers bold, italic, underline,
strikethrough, headings, lists, blockquote, text alignment, links, and undo/redo, reading and
writing content as plain HTML so it themes with the shared design-system tokens.
- Added the `InputTiptapEditor` composed form field.
- Added `useIntlDate` and `useIntlName` hooks under `apps/admin/src/app/i18n`, each returning a
formatter bound to the active UI language. Reading the language from context is what subscribes a
component to a language switch; components that read the `IntlDate`/`IntlName` singletons directly
### Changed
- Moved module generation fully server-side. The admin devtools now sends the raw generator config
(`{ name, table, generate, form, datatable }`) instead of pre-rendered source strings, and the
six frontend source builders were ported verbatim from the admin to
`apps/backend/app/services/module_generator`.
- Refactored `module_controller.ts` (~500 lines) into a thin HTTP controller delegating to focused
`module_generator` service modules: an orchestrator, schema-derived context building, naming and
DB-type helpers, a template renderer, and `builders/` + `writers/` subdirectories.
- Split the root `.env` into per-workspace env files (`apps/backend/.env`, `apps/admin/.env`,
`apps/web/.env`) so each app only ingests the variables it uses. Every `docker-compose.yml`
service now loads its own app's env file, and the admin (Vite) and web (Next.js) apps rely on
their frameworks' native env loading instead of custom root-file merging.
- Renamed cross-app URL env variables: `PAGE_MANAGER_API_URL` → `BACKEND_API_URL` in the web app
and `VITE_PAGE_MANAGER_WEB_URL` → `VITE_WEB_URL` in the admin app.
- File Manager exceptions now extend `ApiException`.
- Extracted the admin i18n context out of `I18nProvider` into `app/i18n/context.ts` and
`app/i18n/useLanguage.ts`, so components reach the active language through a side-effect-free
module instead of importing the provider and executing its module body.
- The selected language is now persisted to local storage as its code rather than as the whole
`Language` object.
### Fixed
- Fixed admin menu highlighting, which marked every ancestor entry active at once.
- Fixes the containing section staying collapsed when a sub-route is opened directly.
- Fixed backend functional test discovery: the `tests/**/*.spec.(ts|js)` globs in `adonisrc.ts`
are not valid for the native `fs.glob` used by Japa v5, so the suite silently ran zero tests.
- Fixed the backend `lint` script path (`../scripts` → `../../scripts`), which made
`npm run lint` fail before linting.
- Removed the stale Japa v1-era `@japa/spec-reporter` wiring from `tests/bootstrap.ts`; the
runner's built-in spec reporter is used instead.
- Renamed `app/exceptions/file_manager_exeptions.ts` to `file_manager_exceptions.ts`, fixing the
filename typo, and updated all imports.
### Removed
- Removed the root `.env` file.
## [1.2.0] - 2026-07-17 ## [1.2.0] - 2026-07-17
### Changed ### Changed
+133 -55
View File
@@ -1,3 +1,18 @@
# CRUX
A monorepo starter for products that need an admin panel, an API, and a public website. It ships an
AdonisJS backend, a React admin panel with a module generator, and a Next.js website that renders
the pages editors publish through the built-in CMS.
| App | Source | Dev port | What it is |
| --------------- | --------------- | -------- | ------------------------------------------------------ |
| Backend API | `apps/backend/` | 3333 | AdonisJS — data, auth, permissions, public content API |
| Admin panel | `apps/admin/` | 5173 | React + Vite — the internal UI |
| Public website | `apps/web/` | 3000 | Next.js — the site visitors see |
| Shared packages | `packages/` | — | UI, hooks, services, types, utils |
Full documentation lives in [`docs/`](./docs/Readme.md).
### Tech Stack ### Tech Stack
#### Backend #### Backend
@@ -6,94 +21,157 @@
- TypeScript - TypeScript
- Lucid ORM - Lucid ORM
- PostgreSQL / MySQL (configurable) - PostgreSQL / MySQL (configurable)
- Redis, S3 / MinIO storage
#### Frontend #### Admin panel
- React - React
- TypeScript - TypeScript
- Vite - Vite
- React Query / Mobx / Axios - React Query / MobX / Axios
- Component-based UI system - Radix UI + Tailwind component system
- React Hook Form + Zod
#### Public website
- Next.js (App Router, React Server Components)
- TypeScript
- next-intl
- Tailwind CSS
### Getting Started ### Getting Started
#### Prerequisites #### Prerequisites
Node.js >= v24.18.0 - Node.js `24.18.0` (see `.nvmrc`; the package manifest requires `>=24.0.0`)
npm >= 11.15.0 - npm `>=11.6.2` (the repo pins `npm@11.15.0`)
Database PostgreSQL - PostgreSQL
#### Installation #### Installation
``` ```bash
git clone gitlab@git.rexsoftproduction.com:crux/admin.git git clone gitlab@git.rexsoftproduction.com:crux/admin.git
cd crux
npm install
``` ```
```bash
cd crux && npm install --force
```
> `--force` is required. `typescript-eslint` does not yet declare support for the TypeScript
> version this repo uses, so a plain `npm install` fails with `ERESOLVE`. Never substitute
> `--legacy-peer-deps` — it strips nested peer dependencies and breaks the backend at runtime. Use
> `npm ci --force` for a reproducible install, and `npm install <package> --force` when adding one.
> See [troubleshooting.md](./troubleshooting.md).
### Configuration ### Configuration
Environment variables are defined in root `.env` Each workspace defines its own environment variables. Copy the workspace's `.env.example` to `.env` and fill in the values:
``` | Env file | Covers |
TZ=UTC | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
PORT=3333 | `apps/backend/.env` | Server (`PORT`, `APP_KEY`), database (`DB_*`), storage (`AWS_*`, `S3_*`), and the public site link (`WEB_APP_URL`, `WEB_REVALIDATE_SECRET`) used to purge its cache |
HOST=localhost | `apps/admin/.env` | `VITE_API_URL` (backend), `VITE_WEB_URL` (public site, used for page previews), `VITE_TINYMCE_API_KEY` |
LOG_LEVEL=info | `apps/web/.env` | `BACKEND_API_URL` (backend base URL for server rendering), `REVALIDATE_SECRET` (must match the backend's `WEB_REVALIDATE_SECRET`), `SITE_URL` (canonical origin) |
APP_KEY=
NODE_ENV=development `docker-compose.yml` loads each service's env from the same per-app files. Inside Docker, service
DB_HOST=127.0.0.1 URLs must use compose service names (`http://backend:3333`), not `localhost`.
DB_PORT=5432
DB_USER=postgres ### Running
DB_PASSWORD=postgres
DB_DATABASE=adonis #### Locally
VITE_API_URL=http://0.0.0.0:3333
VITE_TINYMCE_API_KEY= | Command | Description |
| ----------------------- | ----------------------------------------------- |
| `npm run dev:backend` | Start the AdonisJS backend in development mode |
| `npm run dev:admin-app` | Start the React admin application |
| `npm run dev:web` | Start the Next.js web application |
| `npm run dev:all` | Start backend, admin, and web apps concurrently |
#### With Docker
```bash
docker compose up -d
``` ```
### Module Generator Brings up everything: backend (3333), admin (5173), web (3000), PostgreSQL (5432), Redis (6379),
MinIO (9000 / console 9001) and pgAdmin (5050).
The module generator allows you to create full CRUD modules by selecting: Two quirks worth knowing when a change appears not to take effect:
- The web container keeps `.next` on an anonymous volume that survives rebuilds. Clear it with
`docker compose up -d --force-recreate --renew-anon-volumes web`.
- Backend HMR does not always pick up edits on macOS (bind-mount file events); restart
`crux_backend_adonis` to load them.
### Scripts
#### Build
| Command | Description |
| ----------------------- | ------------------------------------------ |
| `npm run build:backend` | Build the backend application |
| `npm run build:admin` | Type-check and build the admin application |
| `npm run build:web` | Type-check and build the Next.js web app |
#### Test
| Command | Description |
| ---------------------- | ---------------------------------------------- |
| `npm run test:backend` | Backend functional tests (requires a database) |
| `npm run test:admin` | Admin unit tests (Vitest) |
| `npm run test:web` | Web app unit tests (Vitest) |
#### Lint and format
| Command | Description |
| ---------------------- | -------------------------- |
| `npm run lint` | ESLint across the monorepo |
| `npm run lint:fix` | ESLint with autofix |
| `npm run format` | Prettier write |
| `npm run format:check` | Prettier check, no writes |
#### Versions
| Command | Description |
| ----------------------- | ------------------------------------ |
| `npm run version:patch` | Update patch version of all packages |
| `npm run version:minor` | Update minor version of all packages |
| `npm run version:major` | Update major version of all packages |
> Versions are updated without creating git tags.
### Features
#### Module Generator
Create full CRUD modules by selecting:
- Database table - Database table
- Fields and types - Fields and types
- Validation rules - Validation rules
- List view configuration - List view configuration
#### Generated Output **Generated output** — Backend: controllers, validators, routes. Frontend: pages, forms, tables, API.
**Backend**: controllers, validators, routes #### Authentication & Authorization
**Frontend**: pages, forms, tables, API
### Authentication & Authorization - JWT / session-based authentication
- JWT / Session-based authentication
- Role & permission system - Role & permission system
- Policy-based access control - Policy-based access control
### Scripts #### Content Management
#### Development Web Manager is the built-in CMS: editors build localized pages from prebuilt blocks in the admin
panel, preview them privately, and publish to the Next.js site, which purges only the cache entries
the change affects. See [Web Manager](./docs/core/web-manager/Readme.md).
| Command | Description | ### Documentation
| ----------------------- | ---------------------------------------------- |
| `npm run dev:backend` | Start the AdonisJS backend in development mode |
| `npm run dev:admin-app` | Start the React admin application |
| `npm run dev:all` | Start backend and admin app concurrently |
#### Build | Topic | Where |
| ------------------------ | ---------------------------------------------------- |
| Command | Description | | Documentation home | [docs/Readme.md](./docs/Readme.md) |
| ----------------------- | ----------------------------- | | Public website | [apps/web/Readme.md](./apps/web/Readme.md) |
| `npm run build:backend` | Build the backend application | | Architecture | [docs/architecture/](./docs/architecture/Readme.md) |
| `npm run build:admin` | Build the admin application | | Core modules | [docs/core/](./docs/Readme.md#core-modules) |
| Shared packages | [docs/packages/Readme.md](./docs/packages/Readme.md) |
#### Versions | Install and setup issues | [troubleshooting.md](./troubleshooting.md) |
| Command | Description |
| --------------- | ------------------------------------ |
| `npm run patch` | Update Patch version of all packages |
| `npm run minor` | Update Minor version of all packages |
| `npm run major` | Update Major version of all packages |
> Versions are updated without creating git tags.
+3 -1
View File
@@ -1,2 +1,4 @@
VITE_API_URL=http://0.0.0.0:3333 VITE_API_URL=http://localhost:3333
VITE_TINYMCE_API_KEY= VITE_TINYMCE_API_KEY=
VITE_WEB_URL=http://localhost:3000
VITE_LAYOUT=
-8
View File
@@ -1,8 +0,0 @@
# Metronic 9 | All-in-One Tailwind based HTML/React/Next.js Template for Modern Web Applications
### Prerequisites
- Node.js 22.14.0 or higher
- Npm or Yarn
- Tailwind CSS 4.x
- React 19.x
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@crux-kit/admin-app", "name": "@crux-kit/admin-app",
"private": true, "private": true,
"version": "1.2.0", "version": "1.3.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" role="img">
<path
fill="#E05454"
d="m 7.7215115,7.835445 4.3544155,-2.303813 0,4.987339 L 7.7468165,13 7.7215165,7.784809"></path>
<path
fill="#C13383"
d="M 1.9240383,5.531658 6.2531745,7.886082 6.2278445,13 1.8987349,10.518997 1.9240399,5.556964"></path>
<path
fill="#792CA2"
d="M 6.9873475,1 1.8987345,3.936711 6.9873475,6.670899 12.101266,3.936711 6.9873475,1"></path>
</svg>

After

Width:  |  Height:  |  Size: 503 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" role="img">
<path
fill="#1D4ED8"
d="m 7.7215115,7.835445 4.3544155,-2.303813 0,4.987339 L 7.7468165,13 7.7215165,7.784809"></path>
<path
fill="#0057B8"
d="M 1.9240383,5.531658 6.2531745,7.886082 6.2278445,13 1.8987349,10.518997 1.9240399,5.556964"></path>
<path
fill="#FFD700"
d="M 6.9873475,1 1.8987345,3.936711 6.9873475,6.670899 12.101266,3.936711 6.9873475,1"></path>
</svg>

After

Width:  |  Height:  |  Size: 502 B

@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<rect width="400" height="300" fill="#f7f4ec"/>
<rect x="48" y="36" width="168" height="16" rx="8" fill="#1f291f"/>
<rect x="48" y="62" width="120" height="9" rx="4.5" fill="#b9bfab"/>
<g>
<rect x="284" y="36" width="68" height="24" rx="12" fill="none" stroke="#1f291f" stroke-width="1.5"/>
<rect x="298" y="45" width="40" height="6" rx="3" fill="#1f291f"/>
</g>
<g>
<rect x="48" y="96" width="94" height="156" rx="12" fill="#fffdf8" stroke="#d9dacf"/>
<rect x="62" y="114" width="56" height="10" rx="5" fill="#1f291f"/>
<rect x="62" y="136" width="66" height="7" rx="3.5" fill="#ccd1c0"/>
<rect x="62" y="150" width="66" height="7" rx="3.5" fill="#ccd1c0"/>
<rect x="62" y="164" width="48" height="7" rx="3.5" fill="#ccd1c0"/>
<rect x="62" y="222" width="36" height="7" rx="3.5" fill="#1f291f"/>
</g>
<g>
<rect x="153" y="96" width="94" height="156" rx="12" fill="#fffdf8" stroke="#d9dacf"/>
<rect x="167" y="114" width="56" height="10" rx="5" fill="#1f291f"/>
<rect x="167" y="136" width="66" height="7" rx="3.5" fill="#ccd1c0"/>
<rect x="167" y="150" width="66" height="7" rx="3.5" fill="#ccd1c0"/>
<rect x="167" y="164" width="48" height="7" rx="3.5" fill="#ccd1c0"/>
<rect x="167" y="222" width="36" height="7" rx="3.5" fill="#1f291f"/>
</g>
<g>
<rect x="258" y="96" width="94" height="156" rx="12" fill="#fffdf8" stroke="#d9dacf"/>
<rect x="272" y="114" width="56" height="10" rx="5" fill="#1f291f"/>
<rect x="272" y="136" width="66" height="7" rx="3.5" fill="#ccd1c0"/>
<rect x="272" y="150" width="66" height="7" rx="3.5" fill="#ccd1c0"/>
<rect x="272" y="164" width="48" height="7" rx="3.5" fill="#ccd1c0"/>
<rect x="272" y="222" width="36" height="7" rx="3.5" fill="#1f291f"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<rect width="400" height="300" fill="#f7f4ec"/>
<g>
<rect x="40" y="108" width="320" height="84" rx="14" fill="#fffdf8" stroke="#d9dacf"/>
<circle cx="82" cy="150" r="15" fill="none" stroke="#1f291f" stroke-width="2"/>
<path d="M82 144v2m0 4v8" stroke="#1f291f" stroke-width="2" stroke-linecap="round"/>
<rect x="112" y="134" width="140" height="10" rx="5" fill="#1f291f"/>
<rect x="112" y="156" width="112" height="9" rx="4.5" fill="#ccd1c0"/>
<rect x="272" y="136" width="68" height="28" rx="14" fill="#c7ff3f"/>
<rect x="286" y="147" width="40" height="6" rx="3" fill="#1f291f"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 692 B

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<rect width="400" height="300" fill="#f7f4ec"/>
<rect x="92" y="40" width="216" height="18" rx="9" fill="#1f291f"/>
<rect x="128" y="72" width="144" height="10" rx="5" fill="#b9bfab"/>
<g fill="#ccd1c0">
<rect x="48" y="116" width="144" height="10" rx="5"/>
<rect x="48" y="136" width="144" height="10" rx="5"/>
<rect x="48" y="156" width="144" height="10" rx="5"/>
<rect x="48" y="176" width="104" height="10" rx="5"/>
<rect x="208" y="116" width="144" height="10" rx="5"/>
<rect x="208" y="136" width="144" height="10" rx="5"/>
<rect x="208" y="156" width="144" height="10" rx="5"/>
<rect x="208" y="176" width="104" height="10" rx="5"/>
</g>
<g fill="#1f291f">
<rect x="48" y="204" width="56" height="10" rx="5"/>
<rect x="208" y="204" width="56" height="10" rx="5"/>
</g>
<g stroke="#1f291f" stroke-width="2" fill="none" stroke-linecap="round">
<path d="M110 209h10m0 0-4-4m4 4-4 4"/>
<path d="M270 209h10m0 0-4-4m4 4-4 4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<rect width="400" height="300" fill="#f7f4ec"/>
<rect x="124" y="32" width="152" height="16" rx="8" fill="#1f291f"/>
<g>
<rect x="48" y="72" width="304" height="44" rx="10" fill="#fffdf8" stroke="#d9dacf"/>
<rect x="68" y="89" width="152" height="10" rx="5" fill="#1f291f"/>
<path d="M322 88v12m-6-6h12" stroke="#1f291f" stroke-width="2" stroke-linecap="round" fill="none"/>
</g>
<g>
<rect x="48" y="128" width="304" height="84" rx="10" fill="#fffdf8" stroke="#a8c930"/>
<rect x="68" y="145" width="184" height="10" rx="5" fill="#1f291f"/>
<path d="M316 150h12" stroke="#1f291f" stroke-width="2" stroke-linecap="round" fill="none"/>
<rect x="68" y="169" width="264" height="9" rx="4.5" fill="#ccd1c0"/>
<rect x="68" y="187" width="208" height="9" rx="4.5" fill="#ccd1c0"/>
</g>
<g>
<rect x="48" y="224" width="304" height="44" rx="10" fill="#fffdf8" stroke="#d9dacf"/>
<rect x="68" y="241" width="168" height="10" rx="5" fill="#1f291f"/>
<path d="M322 240v12m-6-6h12" stroke="#1f291f" stroke-width="2" stroke-linecap="round" fill="none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<rect width="400" height="300" fill="#f7f4ec"/>
<g>
<rect x="48" y="78" width="152" height="144" rx="12" fill="#e7e9dc" stroke="#d9dacf"/>
<circle cx="166" cy="110" r="12" fill="#c7ff3f"/>
<path d="M64 194 104 142l28 34 18-20 30 38z" fill="#b9bfab"/>
</g>
<g>
<rect x="224" y="86" width="128" height="14" rx="7" fill="#1f291f"/>
<rect x="224" y="116" width="128" height="9" rx="4.5" fill="#ccd1c0"/>
<rect x="224" y="134" width="128" height="9" rx="4.5" fill="#ccd1c0"/>
<rect x="224" y="152" width="96" height="9" rx="4.5" fill="#ccd1c0"/>
<rect x="224" y="180" width="84" height="26" rx="13" fill="#c7ff3f"/>
<rect x="240" y="190" width="52" height="6" rx="3" fill="#1f291f"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 800 B

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<rect width="400" height="300" fill="#f7f4ec"/>
<g>
<rect x="48" y="48" width="304" height="176" rx="14" fill="#e7e9dc" stroke="#d9dacf"/>
<circle cx="288" cy="96" r="17" fill="#c7ff3f"/>
<path d="M76 196 156 96l56 66 34-38 58 72z" fill="#b9bfab"/>
</g>
<rect x="140" y="248" width="120" height="9" rx="4.5" fill="#b9bfab"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

+2
View File
@@ -4,6 +4,8 @@ export const TINYMCE_API_KEY = import.meta.env['VITE_TINYMCE_API_KEY'];
export const API_URL = import.meta.env['VITE_API_URL']; export const API_URL = import.meta.env['VITE_API_URL'];
export const LAYOUT = import.meta.env['VITE_LAYOUT'];
export const IS_DEV_MODE = import.meta.env.MODE === 'development'; export const IS_DEV_MODE = import.meta.env.MODE === 'development';
export const IS_PROD_MODE = import.meta.env.MODE === 'production'; export const IS_PROD_MODE = import.meta.env.MODE === 'production';
+26 -24
View File
@@ -1,28 +1,30 @@
import { LayoutNames, Settings } from './types'; import { LayoutNames, LayoutOptions, Settings } from './types';
const DEFAULT_LAYOUT_OPTIONS: LayoutOptions = {
sidebarCollapse: false,
sidebarTheme: 'light',
headerSticky: true,
headerStickyOffset: 200
};
const layouts = Object.fromEntries(
Object.values(LayoutNames).map((layout) => [layout, { ...DEFAULT_LAYOUT_OPTIONS }])
) as Record<LayoutNames, LayoutOptions>;
export const DEFAULT_CONTAINER_BY_LAYOUT: Record<LayoutNames, 'fixed' | 'fluid'> = {
[LayoutNames.DEFAULT]: 'fixed',
[LayoutNames.HORIZONTAL_MENU]: 'fixed',
[LayoutNames.COMPACT_SIDEBAR]: 'fixed',
[LayoutNames.TWO_COLUMN_SIDEBAR]: 'fixed',
[LayoutNames.DUAL_ROW_HEADER]: 'fixed',
[LayoutNames.MULTIPLE_MENUS]: 'fixed',
[LayoutNames.ADVANCE_MEGA_MENU]: 'fluid',
[LayoutNames.DROPDOWN_MENU]: 'fixed',
[LayoutNames.EXTENDED_HEADER]: 'fixed',
[LayoutNames.DARK_SIDEBAR]: 'fixed'
};
export const APP_SETTINGS: Settings = { export const APP_SETTINGS: Settings = {
layout: '',
container: 'fixed', container: 'fixed',
layouts: { layouts
[LayoutNames.DEFAULT]: {
sidebarCollapse: false,
sidebarTheme: 'light'
},
[LayoutNames.HORIZONTAL_MENU]: {
headerSticky: true,
headerStickyOffset: 200
},
[LayoutNames.DUAL_ROW_HEADER]: {
headerSticky: true,
headerStickyOffset: 200
},
[LayoutNames.ADVANCE_MEGA_MENU]: {
headerSticky: true,
headerStickyOffset: 200
},
[LayoutNames.EXTENDED_HEADER]: {
headerSticky: true,
headerStickyOffset: 200
}
}
}; };
+8 -23
View File
@@ -33,29 +33,14 @@ export enum LayoutNames {
DARK_SIDEBAR = 'dark-sidebar-layout', // demo10 DARK_SIDEBAR = 'dark-sidebar-layout', // demo10
} }
export interface LayoutOptions {
sidebarCollapse: boolean;
sidebarTheme: 'light' | 'dark';
headerSticky: boolean;
headerStickyOffset: number;
}
export interface Settings { export interface Settings {
container: 'fixed' | 'fluid'; container: 'fixed' | 'fluid';
layout: string; layouts: Record<LayoutNames, LayoutOptions>;
layouts: {
[LayoutNames.DEFAULT]: {
sidebarCollapse: boolean;
sidebarTheme: 'light' | 'dark';
};
[LayoutNames.HORIZONTAL_MENU]: {
headerSticky: boolean;
headerStickyOffset: number;
};
[LayoutNames.DUAL_ROW_HEADER]: {
headerSticky: boolean;
headerStickyOffset: number;
};
[LayoutNames.ADVANCE_MEGA_MENU]: {
headerSticky: boolean;
headerStickyOffset: number;
};
[LayoutNames.EXTENDED_HEADER]: {
headerSticky: boolean;
headerStickyOffset: number;
};
};
} }
+4
View File
@@ -0,0 +1,4 @@
import { createContext } from 'react';
import { type I18nProviderProps } from '#app/i18n/types';
export default createContext<I18nProviderProps | null>(null);
+99 -1
View File
@@ -1,7 +1,105 @@
{ {
"ACTION.BACK": "رجوع",
"ACTION.SAVE": "حفظ",
"ACTION.CREATE": "إنشاء",
"ACTION.INCLUDE": "تضمين",
"ACTION.GENERATE": "توليد",
"ACTION.REMOVE": "إزالة",
"ACTION.CANCEL": "إلغاء",
"ACTION.CONFIRM": "تأكيد",
"ACTION.SUBMIT": "إرسال",
"ACTION.SEARCH": "بحث",
"ACTION.SWAP": "تبديل",
"ACTION.MERGE": "دمج",
"ACTION.SPLIT": "تقسيم",
"ACTION.RESET": "إعادة تعيين",
"ACTION.PREVIEW": "معاينة",
"ACTION.UPLOAD": "رفع",
"ACTION.DELETE": "حذف",
"ACTION.CLOSE": "إغلاق",
"ACTION.CLEAR": "مسح",
"ACTION.CLEAR_FILTERS": "مسح عوامل التصفية",
"ACTION.EDIT": "تعديل",
"STATE.CREATING": "جارٍ الإنشاء",
"STATE.GENERATING": "جارٍ التوليد",
"STATE.SAVING": "جارٍ الحفظ",
"STATE.REMOVING": "جارٍ الإزالة",
"STATE.LOADING": "جارٍ التحميل",
"STATE.UPDATING": "جارٍ التحديث",
"ERROR": "خطأ",
"PERMISSION": "صلاحية",
"PERMISSION.PERMISSIONS": "الصلاحيات",
"PERMISSION.PERMISSION_NAME": "اسم الصلاحية",
"PERMISSION.SLUG": "Slug",
"PERMISSION.EDIT_PERMISSION": "تعديل الصلاحية",
"ROLE": "دور",
"ROLE.ROLES": "الأدوار",
"ROLE.ROLE_NAME": "اسم الدور",
"ROLE.SLUG": "Slug",
"ROLE.EDIT_ROLE": "تعديل الدور",
"USER": "مستخدم",
"USER.EDIT_USER": "تعديل المستخدم",
"USER.STATUS.ACTIVE": "نشط",
"USER.STATUS.INACTIVE": "غير نشط",
"USER.MENU.MY_PROFILE": "الملف الشخصي الخاص بي", "USER.MENU.MY_PROFILE": "الملف الشخصي الخاص بي",
"USER.MENU.MY_ACCOUNT": "حسابي", "USER.MENU.MY_ACCOUNT": "حسابي",
"USER.MENU.LANGUAGE": "لغة",
"USER.MENU.DARK_MODE": "الوضع المظلم", "USER.MENU.DARK_MODE": "الوضع المظلم",
"USER.MENU.LOGOUT": "تسجيل الخروج", "USER.MENU.LOGOUT": "تسجيل الخروج",
"USER.MENU.LANGUAGE": "لغة" "SUCCESS": "تم بنجاح",
"ACTIONS": "الإجراءات",
"FILTER": "تصفية",
"SELECTED": "محدد",
"NAME": "الاسم",
"FIRST_NAME": "الاسم الأول",
"LAST_NAME": "اسم العائلة",
"EMAIL": "البريد الإلكتروني",
"SELECT": "اختر",
"SELECT_OPTION": "اختر خياراً",
"SELECT_ALL": "تحديد الكل",
"SELECT_ALL_WITH_OPTS_LENGTH": "تحديد الكل ({length} خيارات)",
"STATUS": "الحالة",
"PASSWORD": "كلمة المرور",
"NEW_PASSWORD": "كلمة المرور الجديدة",
"PASSWORD_CONFIRM": "تأكيد كلمة المرور",
"MODULE_NAME": "اسم الوحدة",
"RETURN_HOME": "العودة إلى الرئيسية",
"NO_ITEMS_FOUND": "لم يتم العثور على عناصر",
"NO_RESULTS_FOUND": "لم يتم العثور على نتائج",
"PLUS_MORE_WITH_NUMBER": "+ {number} أخرى",
"ADMIN.ADMIN_MANAGEMENT": "إدارة المشرفين",
"ADMIN.ADMINS": "المشرفون",
"DASHBOARD": "لوحة التحكم",
"FORM.ERROR.REQUIRED": "مطلوب",
"FORM.ERROR.INVALID_EMAIL_ADDRESS": "عنوان بريد إلكتروني غير صالح",
"FORM.ERROR.PASSWORDS_MUST_MATCH": "يجب أن تتطابق كلمتا المرور",
"FORM.ERROR.MIN_PASSWORD_LENGTH_DESC": "الحد الأدنى لطول كلمة المرور - 8 أحرف.",
"FORM.ERROR.SOMETHING_WENT_WRONG": "حدث خطأ ما",
"TABLE.COMMON.COLUMNS": "الأعمدة",
"TABLE.COMMON.NO_DATA_AVAILABLE": "لا توجد بيانات متاحة",
"TABLE.COMMON.ROWS_PER_PAGE": "عدد الصفوف في الصفحة",
"TABLE.COMMON.GO_TO_PREVIOUS_PAGE": "الانتقال إلى الصفحة السابقة",
"TABLE.COMMON.GO_TO_NEXT_PAGE": "الانتقال إلى الصفحة التالية",
"PROFILE.PROFILE_SETTINGS": "إعدادات الملف الشخصي",
"PROFILE.AVATAR_DESCRIPTION": "نوصي باستخدام صور لا تقل أبعادها عن 98 × 98 بكسل (بحجم يصل إلى 4 ميغابايت) بصيغة PNG أو JPG أو JPEG.",
"PROFILE.AVATAR_ALT": "الصورة الرمزية",
"PROFILE.AVATAR_SETTINGS": "إعدادات الصورة الرمزية",
"PROFILE.AVATAR_CROP_ME": "قص الصورة",
"PROFILE.EDIT_PROFILE_IMAGE": "تعديل صورة الملف الشخصي",
"MODULE.COMMON.REMOVE_ITEM_QUESTION": "هل أنت متأكد من رغبتك في إزالة {target}؟",
"MODULE.COMMON.ARE_YOU_SURE?": "هل أنت متأكد؟",
"MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC": "لا يمكن التراجع عن هذا الإجراء.",
"MODULE.COMMON.CREATE_NEW_ENTITY": "إنشاء {entity} جديد",
"MODULE.COMMON.CREATE_NEW_USER": "إنشاء مستخدم جديد",
"MESSAGE.PLEASE_WAIT": "يرجى الانتظار",
"MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS": "يرجى الانتظار، قد يستغرق هذا بعض الوقت",
"COMMON.SELECT_FILE_TO_VIEW_ITS_CONTENT": "اختر ملفاً لعرض محتوياته",
"COMPONENT.FILE_INPUT.SELECTED_NUM_OF_FILES": "تم اختيار {num} ملف",
"COMPONENT.FILE_INPUT.SELECT_FILE_NO_FILE_SELECTED": "اختر ملفاً، لم يتم اختيار أي ملف",
"COMPONENT.FILE_INPUT.OPTION.FROM_CLOUD_STORAGE": "من التخزين السحابي",
"COMPONENT.FILE_INPUT.OPTION.FROM_LOCAL_MACHINE": "من الجهاز المحلي",
"COMPONENT.FILE_INPUT.OPTION.CLEAR": "مسح",
"COMPONENT.FILE_INPUT.SHOW_SELECTED_FILES": "عرض الملفات المختارة",
"COMPONENT.FILE_INPUT.SELECTED_FILES": "الملفات المختارة",
"COMPONENT.FILE_INPUT.REMOVE_NAME": "إزالة {name}"
} }
-15
View File
@@ -20,28 +20,23 @@
"ACTION.CLEAR": "Clear", "ACTION.CLEAR": "Clear",
"ACTION.CLEAR_FILTERS": "Clear filters", "ACTION.CLEAR_FILTERS": "Clear filters",
"ACTION.EDIT": "Edit", "ACTION.EDIT": "Edit",
"STATE.CREATING": "Creating", "STATE.CREATING": "Creating",
"STATE.GENERATING": "Generating", "STATE.GENERATING": "Generating",
"STATE.SAVING": "Saving", "STATE.SAVING": "Saving",
"STATE.REMOVING": "Removing", "STATE.REMOVING": "Removing",
"STATE.LOADING": "Loading", "STATE.LOADING": "Loading",
"STATE.UPDATING": "Updating", "STATE.UPDATING": "Updating",
"ERROR": "Error", "ERROR": "Error",
"PERMISSION": "Permission", "PERMISSION": "Permission",
"PERMISSION.PERMISSIONS": "Permissions", "PERMISSION.PERMISSIONS": "Permissions",
"PERMISSION.PERMISSION_NAME": "Permission name", "PERMISSION.PERMISSION_NAME": "Permission name",
"PERMISSION.SLUG": "Slug", "PERMISSION.SLUG": "Slug",
"PERMISSION.EDIT_PERMISSION": "Edit permission", "PERMISSION.EDIT_PERMISSION": "Edit permission",
"ROLE": "Role", "ROLE": "Role",
"ROLE.ROLES": "Roles", "ROLE.ROLES": "Roles",
"ROLE.ROLE_NAME": "Role name", "ROLE.ROLE_NAME": "Role name",
"ROLE.SLUG": "Slug", "ROLE.SLUG": "Slug",
"ROLE.EDIT_ROLE": "Edit role", "ROLE.EDIT_ROLE": "Edit role",
"USER": "User", "USER": "User",
"USER.EDIT_USER": "Edit user", "USER.EDIT_USER": "Edit user",
"USER.STATUS.ACTIVE": "Active", "USER.STATUS.ACTIVE": "Active",
@@ -51,7 +46,6 @@
"USER.MENU.LANGUAGE": "Language", "USER.MENU.LANGUAGE": "Language",
"USER.MENU.DARK_MODE": "Dark Mode", "USER.MENU.DARK_MODE": "Dark Mode",
"USER.MENU.LOGOUT": "Logout", "USER.MENU.LOGOUT": "Logout",
"SUCCESS": "Success", "SUCCESS": "Success",
"ACTIONS": "Actions", "ACTIONS": "Actions",
"FILTER": "Filter", "FILTER": "Filter",
@@ -73,42 +67,33 @@
"NO_ITEMS_FOUND": "No items found", "NO_ITEMS_FOUND": "No items found",
"NO_RESULTS_FOUND": "No results found", "NO_RESULTS_FOUND": "No results found",
"PLUS_MORE_WITH_NUMBER": "+ {number} more", "PLUS_MORE_WITH_NUMBER": "+ {number} more",
"ADMIN.ADMIN_MANAGEMENT": "Admin Management", "ADMIN.ADMIN_MANAGEMENT": "Admin Management",
"ADMIN.ADMINS": "Admins", "ADMIN.ADMINS": "Admins",
"DASHBOARD": "Dashboard", "DASHBOARD": "Dashboard",
"FORM.ERROR.REQUIRED": "Required", "FORM.ERROR.REQUIRED": "Required",
"FORM.ERROR.INVALID_EMAIL_ADDRESS": "Invalid email address", "FORM.ERROR.INVALID_EMAIL_ADDRESS": "Invalid email address",
"FORM.ERROR.PASSWORDS_MUST_MATCH": "Passwords must match", "FORM.ERROR.PASSWORDS_MUST_MATCH": "Passwords must match",
"FORM.ERROR.MIN_PASSWORD_LENGTH_DESC": "Min. password length - 8 chars.", "FORM.ERROR.MIN_PASSWORD_LENGTH_DESC": "Min. password length - 8 chars.",
"FORM.ERROR.SOMETHING_WENT_WRONG": "Something went wrong", "FORM.ERROR.SOMETHING_WENT_WRONG": "Something went wrong",
"TABLE.COMMON.COLUMNS": "Columns", "TABLE.COMMON.COLUMNS": "Columns",
"TABLE.COMMON.NO_DATA_AVAILABLE": "No data available", "TABLE.COMMON.NO_DATA_AVAILABLE": "No data available",
"TABLE.COMMON.ROWS_PER_PAGE": "Rows per page", "TABLE.COMMON.ROWS_PER_PAGE": "Rows per page",
"TABLE.COMMON.GO_TO_PREVIOUS_PAGE": "Go to previous page", "TABLE.COMMON.GO_TO_PREVIOUS_PAGE": "Go to previous page",
"TABLE.COMMON.GO_TO_NEXT_PAGE": "Go to next page", "TABLE.COMMON.GO_TO_NEXT_PAGE": "Go to next page",
"PROFILE.PROFILE_SETTINGS": "Profile Settings", "PROFILE.PROFILE_SETTINGS": "Profile Settings",
"PROFILE.AVATAR_DESCRIPTION": "We recommend using images that are at least 98 x 98 pixels in size (file size up to 4 MB) in PNG, JPG, JPEG format.", "PROFILE.AVATAR_DESCRIPTION": "We recommend using images that are at least 98 x 98 pixels in size (file size up to 4 MB) in PNG, JPG, JPEG format.",
"PROFILE.AVATAR_ALT": "Avatar", "PROFILE.AVATAR_ALT": "Avatar",
"PROFILE.AVATAR_SETTINGS": "Avatar Settings", "PROFILE.AVATAR_SETTINGS": "Avatar Settings",
"PROFILE.AVATAR_CROP_ME": "Crop me", "PROFILE.AVATAR_CROP_ME": "Crop me",
"PROFILE.EDIT_PROFILE_IMAGE": "Edit profile image", "PROFILE.EDIT_PROFILE_IMAGE": "Edit profile image",
"MODULE.COMMON.REMOVE_ITEM_QUESTION": "Are you sure you want to remove {target}?", "MODULE.COMMON.REMOVE_ITEM_QUESTION": "Are you sure you want to remove {target}?",
"MODULE.COMMON.ARE_YOU_SURE?": "Are you sure?", "MODULE.COMMON.ARE_YOU_SURE?": "Are you sure?",
"MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC": "This action cannot be undone.", "MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC": "This action cannot be undone.",
"MODULE.COMMON.CREATE_NEW_ENTITY": "Create new {entity}", "MODULE.COMMON.CREATE_NEW_ENTITY": "Create new {entity}",
"MODULE.COMMON.CREATE_NEW_USER": "Create new user", "MODULE.COMMON.CREATE_NEW_USER": "Create new user",
"MESSAGE.PLEASE_WAIT": "Please wait", "MESSAGE.PLEASE_WAIT": "Please wait",
"MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS": "Please wait, this may take few moments", "MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS": "Please wait, this may take few moments",
"COMMON.SELECT_FILE_TO_VIEW_ITS_CONTENT": "Select a file to view its contents", "COMMON.SELECT_FILE_TO_VIEW_ITS_CONTENT": "Select a file to view its contents",
"COMPONENT.FILE_INPUT.SELECTED_NUM_OF_FILES": "{num} file(s) selected", "COMPONENT.FILE_INPUT.SELECTED_NUM_OF_FILES": "{num} file(s) selected",
"COMPONENT.FILE_INPUT.SELECT_FILE_NO_FILE_SELECTED": "Select file, no file selected", "COMPONENT.FILE_INPUT.SELECT_FILE_NO_FILE_SELECTED": "Select file, no file selected",
"COMPONENT.FILE_INPUT.OPTION.FROM_CLOUD_STORAGE": "From cloud storage", "COMPONENT.FILE_INPUT.OPTION.FROM_CLOUD_STORAGE": "From cloud storage",
-15
View File
@@ -20,28 +20,23 @@
"ACTION.CLEAR": "Очистити", "ACTION.CLEAR": "Очистити",
"ACTION.CLEAR_FILTERS": "Очистити фільтри", "ACTION.CLEAR_FILTERS": "Очистити фільтри",
"ACTION.EDIT": "Редагувати", "ACTION.EDIT": "Редагувати",
"STATE.CREATING": "Створення", "STATE.CREATING": "Створення",
"STATE.GENERATING": "Генерування", "STATE.GENERATING": "Генерування",
"STATE.SAVING": "Збереження", "STATE.SAVING": "Збереження",
"STATE.REMOVING": "Видалення", "STATE.REMOVING": "Видалення",
"STATE.LOADING": "Завантаження", "STATE.LOADING": "Завантаження",
"STATE.UPDATING": "Оновлюється", "STATE.UPDATING": "Оновлюється",
"ERROR": "Помилка", "ERROR": "Помилка",
"PERMISSION": "Дозвіл", "PERMISSION": "Дозвіл",
"PERMISSION.PERMISSIONS": "Дозволи", "PERMISSION.PERMISSIONS": "Дозволи",
"PERMISSION.PERMISSION_NAME": "Назва дозволу", "PERMISSION.PERMISSION_NAME": "Назва дозволу",
"PERMISSION.SLUG": "Slug", "PERMISSION.SLUG": "Slug",
"PERMISSION.EDIT_PERMISSION": "Редагувати дозвіл", "PERMISSION.EDIT_PERMISSION": "Редагувати дозвіл",
"ROLE": "Роль", "ROLE": "Роль",
"ROLE.ROLES": "Ролі", "ROLE.ROLES": "Ролі",
"ROLE.ROLE_NAME": "Назва ролі", "ROLE.ROLE_NAME": "Назва ролі",
"ROLE.SLUG": "Slug", "ROLE.SLUG": "Slug",
"ROLE.EDIT_ROLE": "Редагувати роль", "ROLE.EDIT_ROLE": "Редагувати роль",
"USER": "Користувач", "USER": "Користувач",
"USER.EDIT_USER": "Редагувати користувача", "USER.EDIT_USER": "Редагувати користувача",
"USER.STATUS.ACTIVE": "Активний", "USER.STATUS.ACTIVE": "Активний",
@@ -51,7 +46,6 @@
"USER.MENU.LANGUAGE": "Мова", "USER.MENU.LANGUAGE": "Мова",
"USER.MENU.DARK_MODE": "Темний режим", "USER.MENU.DARK_MODE": "Темний режим",
"USER.MENU.LOGOUT": "Вийти", "USER.MENU.LOGOUT": "Вийти",
"SUCCESS": "Успіх", "SUCCESS": "Успіх",
"ACTIONS": "Дії", "ACTIONS": "Дії",
"FILTER": "Фільтр", "FILTER": "Фільтр",
@@ -73,42 +67,33 @@
"NO_ITEMS_FOUND": "Не знайдено жодного елемента", "NO_ITEMS_FOUND": "Не знайдено жодного елемента",
"NO_RESULTS_FOUND": "Результатів не знайдено", "NO_RESULTS_FOUND": "Результатів не знайдено",
"PLUS_MORE_WITH_NUMBER": "+ {number} ще", "PLUS_MORE_WITH_NUMBER": "+ {number} ще",
"ADMIN.ADMIN_MANAGEMENT": "Адмін Управління", "ADMIN.ADMIN_MANAGEMENT": "Адмін Управління",
"ADMIN.ADMINS": "Адміністратори", "ADMIN.ADMINS": "Адміністратори",
"DASHBOARD": "Дашборд", "DASHBOARD": "Дашборд",
"FORM.ERROR.REQUIRED": "Обов'язково", "FORM.ERROR.REQUIRED": "Обов'язково",
"FORM.ERROR.INVALID_EMAIL_ADDRESS": "Недійсна адреса електронної пошти", "FORM.ERROR.INVALID_EMAIL_ADDRESS": "Недійсна адреса електронної пошти",
"FORM.ERROR.PASSWORDS_MUST_MATCH": "Паролі мають збігатися", "FORM.ERROR.PASSWORDS_MUST_MATCH": "Паролі мають збігатися",
"FORM.ERROR.MIN_PASSWORD_LENGTH_DESC": "Мінімальна довжина пароля - 8 символів.", "FORM.ERROR.MIN_PASSWORD_LENGTH_DESC": "Мінімальна довжина пароля - 8 символів.",
"FORM.ERROR.SOMETHING_WENT_WRONG": "Щось пішло не так", "FORM.ERROR.SOMETHING_WENT_WRONG": "Щось пішло не так",
"TABLE.COMMON.COLUMNS": "Колонки", "TABLE.COMMON.COLUMNS": "Колонки",
"TABLE.COMMON.NO_DATA_AVAILABLE": "Немає доступних даних", "TABLE.COMMON.NO_DATA_AVAILABLE": "Немає доступних даних",
"TABLE.COMMON.ROWS_PER_PAGE": "Рядків на сторінці", "TABLE.COMMON.ROWS_PER_PAGE": "Рядків на сторінці",
"TABLE.COMMON.GO_TO_PREVIOUS_PAGE": "Перейти на попередню сторінку", "TABLE.COMMON.GO_TO_PREVIOUS_PAGE": "Перейти на попередню сторінку",
"TABLE.COMMON.GO_TO_NEXT_PAGE": "Перейти на наступну сторінку", "TABLE.COMMON.GO_TO_NEXT_PAGE": "Перейти на наступну сторінку",
"PROFILE.PROFILE_SETTINGS": "Налаштування профілю", "PROFILE.PROFILE_SETTINGS": "Налаштування профілю",
"PROFILE.AVATAR_DESCRIPTION": "Радимо використовувати зображення розміром принаймні 98 x 98 пікселів (розмір файлу - до 4 МБ) у форматі PNG, JPG , JPEG.", "PROFILE.AVATAR_DESCRIPTION": "Радимо використовувати зображення розміром принаймні 98 x 98 пікселів (розмір файлу - до 4 МБ) у форматі PNG, JPG , JPEG.",
"PROFILE.AVATAR_ALT": "Аватар", "PROFILE.AVATAR_ALT": "Аватар",
"PROFILE.AVATAR_SETTINGS": "Налаштування аватара", "PROFILE.AVATAR_SETTINGS": "Налаштування аватара",
"PROFILE.AVATAR_CROP_ME": "Обріж мене", "PROFILE.AVATAR_CROP_ME": "Обріж мене",
"PROFILE.EDIT_PROFILE_IMAGE": "Редагувати зображення профілю", "PROFILE.EDIT_PROFILE_IMAGE": "Редагувати зображення профілю",
"MODULE.COMMON.REMOVE_ITEM_QUESTION": "Ви впевнені, що хочете видалити {target}?", "MODULE.COMMON.REMOVE_ITEM_QUESTION": "Ви впевнені, що хочете видалити {target}?",
"MODULE.COMMON.ARE_YOU_SURE?": "Ви впевнені?", "MODULE.COMMON.ARE_YOU_SURE?": "Ви впевнені?",
"MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC": "Цю дію не можна скасувати.", "MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC": "Цю дію не можна скасувати.",
"MODULE.COMMON.CREATE_NEW_ENTITY": "Створити новий {entity}", "MODULE.COMMON.CREATE_NEW_ENTITY": "Створити новий {entity}",
"MODULE.COMMON.CREATE_NEW_USER": "Створити нового користувача", "MODULE.COMMON.CREATE_NEW_USER": "Створити нового користувача",
"MESSAGE.PLEASE_WAIT": "Будь ласка, зачекайте", "MESSAGE.PLEASE_WAIT": "Будь ласка, зачекайте",
"MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS": "Зачекайте хвилинку, це може зайняти деякий час", "MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS": "Зачекайте хвилинку, це може зайняти деякий час",
"COMMON.SELECT_FILE_TO_VIEW_ITS_CONTENT": "Виберіть файл, щоб переглянути його вміст", "COMMON.SELECT_FILE_TO_VIEW_ITS_CONTENT": "Виберіть файл, щоб переглянути його вміст",
"COMPONENT.FILE_INPUT.SELECTED_NUM_OF_FILES": "Вибрано {num} файл(и)", "COMPONENT.FILE_INPUT.SELECTED_NUM_OF_FILES": "Вибрано {num} файл(и)",
"COMPONENT.FILE_INPUT.SELECT_FILE_NO_FILE_SELECTED": "Виберіть файл, файл не вибрано", "COMPONENT.FILE_INPUT.SELECT_FILE_NO_FILE_SELECTED": "Виберіть файл, файл не вибрано",
"COMPONENT.FILE_INPUT.OPTION.FROM_CLOUD_STORAGE": "З хмарного сховища", "COMPONENT.FILE_INPUT.OPTION.FROM_CLOUD_STORAGE": "З хмарного сховища",
+20
View File
@@ -0,0 +1,20 @@
import { useMemo } from 'react';
import useLanguage from '#app/i18n/useLanguage';
import { IntlDate } from '#infrastructure/IntlDate';
type DisplayFormat = Parameters<typeof IntlDate.formatToDisplay>[0];
type SourceFormat = Parameters<typeof IntlDate.parseSource>[1];
const useIntlDate = () => {
const { currenLanguage } = useLanguage();
return useMemo(
() =>
(value: string, format: DisplayFormat, sourceFormat: SourceFormat = 'UNIVERSAL_ISO') =>
IntlDate.parseSource(value, sourceFormat).formatToDisplay(format),
// eslint-disable-next-line react-hooks/exhaustive-deps
[currenLanguage.code]
);
};
export default useIntlDate;
+18
View File
@@ -0,0 +1,18 @@
import { useMemo } from 'react';
import useLanguage from '#app/i18n/useLanguage';
import { IntlName } from '#infrastructure/IntlName';
type NameInput = Parameters<typeof IntlName.formatToDisplay>[0];
type NameFormat = Parameters<typeof IntlName.formatToDisplay>[1];
const useIntlName = () => {
const { currenLanguage } = useLanguage();
return useMemo(
() => (input: NameInput, format: NameFormat) => IntlName.formatToDisplay(input, format),
// eslint-disable-next-line react-hooks/exhaustive-deps
[currenLanguage.code]
);
};
export default useIntlName;
+12
View File
@@ -0,0 +1,12 @@
import { useContext } from 'react';
import TranslationsContext from '#app/i18n/context';
const useLanguage = () => {
const context = useContext(TranslationsContext);
if (!context) throw new Error('useLanguage must be used within I18nProvider');
return context;
};
export default useLanguage;
@@ -0,0 +1,24 @@
# Default layout
**Metronic 9 reference:** [Demo 1](https://keenthemes.com/metronic/tailwind/react/demo1)
A fixed sidebar carrying the application menu next to a fixed header. The sidebar can be collapsed
to icons, and the choice is remembered in settings under
`layouts.default-layout.sidebarCollapse`. Below `lg` the sidebar moves into a sheet opened from the
header.
## Composition
| Piece | Source |
| ----------------------- | --------------------------------------------- |
| Sidebar menu | `SidebarAccordionMenu` from `../shared` |
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
| Footer | `AppFooter` from `../shared` |
| Account menu | `UserAvatarMenu` from `../shared` |
Layout-specific: `Sidebar`, `SidebarHeader` (logo plus the collapse toggle), `Header`.
## Notes
- Body classes `sidebar-fixed`, `header-fixed` and `layout-initialized` are added on mount and
removed on unmount, so switching to another layout leaves no styling behind.
@@ -1,41 +0,0 @@
import { ChevronRight } from 'lucide-react';
import { Fragment } from 'react';
import { useLocation } from 'react-router';
import { cn } from '@crux-kit/utils';
import { MenuItem } from '#app/config/types';
import { useMenuConfig } from '#app/menu';
import { useMenu } from '#shared/hooks/useMenu';
export function Breadcrumb() {
const { pathname } = useLocation();
const { getBreadcrumb, isActive } = useMenu(pathname);
const menuConfig = useMenuConfig();
const items: MenuItem[] = getBreadcrumb(menuConfig);
if (items.length === 0) {
return null;
}
return (
<div className="mb-2.5 flex items-center gap-1.25 text-xs font-medium lg:mb-0 lg:text-sm">
{items.map((item, index) => {
const last = index === items.length - 1;
const active = item.path ? isActive(item.path) : false;
return (
<Fragment key={`root-${index}`}>
<span
className={cn(active ? 'text-mono' : 'text-secondary-foreground')}
key={`item-${index}`}
>
{item.title}
</span>
{!last && (
<ChevronRight className="text-muted-foreground size-3.5" key={`separator-${index}`} />
)}
</Fragment>
);
})}
</div>
);
}
@@ -1,19 +0,0 @@
import { Outlet } from 'react-router';
import { useIsMobile } from '@crux-kit/hooks';
import { Container } from '#shared/widgets/common/Container';
import { Breadcrumb } from './Breadcrumb';
export function Content() {
const mobile = useIsMobile();
return (
<div className="content grow pt-5" role="content">
{mobile && (
<Container>
<Breadcrumb />
</Container>
)}
<Outlet />
</div>
);
}
@@ -1,42 +0,0 @@
import { generalSettings } from '#app/config/general';
import { Container } from '#shared/widgets/common/Container';
export function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="footer">
<Container>
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
<span className="text-muted-foreground">{currentYear} &copy;</span>
<a
href="https://keenthemes.com"
target="_blank"
className="text-secondary-foreground hover:text-primary"
>
Keenthemes Inc.
</a>
</div>
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
Docs
</a>
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
Purchase
</a>
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
FAQ
</a>
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
Support
</a>
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
License
</a>
</nav>
</div>
</Container>
</footer>
);
}
@@ -1,99 +1,57 @@
import { Menu, SquareChevronRight } from 'lucide-react'; import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { useIsMobile, useScrollPosition } from '@crux-kit/hooks'; import { useIsMobile, useScrollPosition } from '@crux-kit/hooks';
import { cn } from '@crux-kit/utils'; import { cn } from '@crux-kit/utils';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button'; import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet'; import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { Container } from '#shared/widgets/common/Container'; import { Container } from '#shared/widgets/common/Container';
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu'; import { AppLogo, UserAvatarMenu } from '../../shared';
import { MegaMenu } from './MegaMenu';
import { MegaMenuMobile } from './MegaMenuMobile';
import { SidebarMenu } from './SidebarMenu'; import { SidebarMenu } from './SidebarMenu';
export function Header() { export function Header() {
const [isSidebarSheetOpen, setIsSidebarSheetOpen] = useState(false); const [isSidebarSheetOpen, setIsSidebarSheetOpen] = useState(false);
const [isMegaMenuSheetOpen, setIsMegaMenuSheetOpen] = useState(false);
const { pathname } = useLocation(); const { pathname } = useLocation();
const mobileMode = useIsMobile(); const isMobile = useIsMobile();
const scrollPosition = useScrollPosition(); const scrollPosition = useScrollPosition();
const headerSticky: boolean = scrollPosition > 0; const headerSticky: boolean = scrollPosition > 0;
// Close sheet when route changes
useEffect(() => { useEffect(() => {
setIsSidebarSheetOpen(false); setIsSidebarSheetOpen(false);
setIsMegaMenuSheetOpen(false);
}, [pathname]); }, [pathname]);
return ( return (
<header <header
className={cn( className={cn(
'header bg-background fixed start-0 end-0 top-0 z-10 flex shrink-0 items-stretch border-b border-transparent pe-(--removed-body-scroll-bar-size,0px)', 'header bg-background fixed inset-s-0 inset-e-0 top-0 z-10 flex shrink-0 items-stretch border-b border-transparent pe-(--removed-body-scroll-bar-size,0px)',
headerSticky && 'border-border border-b' headerSticky && 'border-border border-b'
)} )}
> >
<Container className="flex items-stretch justify-between lg:gap-4"> <Container className="flex items-stretch justify-between lg:gap-4">
{/* HeaderLogo */}
<div className="flex items-center gap-2.5 lg:hidden"> <div className="flex items-center gap-2.5 lg:hidden">
<Link to="/" className="shrink-0"> <AppLogo className="h-6.25" />
<img
src={toAbsoluteUrl('/media/app/mini-logo.svg')} {isMobile && (
className="h-6.25 w-full" <Sheet open={isSidebarSheetOpen} onOpenChange={setIsSidebarSheetOpen}>
alt="mini-logo" <SheetTrigger asChild>
/> <Button variant="ghost" mode="icon">
</Link> <Menu className="text-muted-foreground/70" />
<div className="flex items-center"> </Button>
{mobileMode && ( </SheetTrigger>
<Sheet open={isSidebarSheetOpen} onOpenChange={setIsSidebarSheetOpen}> <SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
<SheetTrigger asChild> <SheetHeader className="space-y-0 p-0" />
<Button variant="ghost" mode="icon"> <SheetBody className="overflow-y-auto p-0">
<Menu className="text-muted-foreground/70" /> <SidebarMenu />
</Button> </SheetBody>
</SheetTrigger> </SheetContent>
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}> </Sheet>
<SheetHeader className="space-y-0 p-0" /> )}
<SheetBody className="overflow-y-auto p-0">
<SidebarMenu />
</SheetBody>
</SheetContent>
</Sheet>
)}
{mobileMode && (
<Sheet open={isMegaMenuSheetOpen} onOpenChange={setIsMegaMenuSheetOpen}>
<SheetTrigger asChild>
<Button variant="ghost" mode="icon">
<SquareChevronRight className="text-muted-foreground/70" />
</Button>
</SheetTrigger>
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" />
<SheetBody className="overflow-y-auto p-0">
<MegaMenuMobile />
</SheetBody>
</SheetContent>
</Sheet>
)}
</div>
</div> </div>
{/* Main Content (MegaMenu) */} <div className="flex grow items-center justify-end gap-3">
{!mobileMode && <MegaMenu />} <UserAvatarMenu />
{/* HeaderTopbar */}
<div className="flex items-center gap-3">
<UserDropdownMenu
trigger={
<img
className="size-9 shrink-0 cursor-pointer rounded-full border-2 border-green-500"
src={toAbsoluteUrl('/media/avatars/300-2.png')}
alt="User Avatar"
/>
}
/>
</div> </div>
</Container> </Container>
</header> </header>
@@ -1,119 +0,0 @@
import {
NavigationMenu,
// NavigationMenuContent,
// NavigationMenuItem,
// NavigationMenuLink,
NavigationMenuList
// NavigationMenuTrigger,
} from '#shared/ui/NavigationMenu';
// import { useMenu } from '#shared/hooks/use_menu';
// import { cn } from '@crux-kit/utils';
// import { MegaMenuSubAccount } from '#shared/widgets/mega_menu/mega_menu_sub_account';
// import { MegaMenuSubAuth } from '#shared/widgets/mega_menu/mega_menu_sub_auth';
// import { MegaMenuSubNetwork } from '#shared/widgets/mega_menu/mega_menu_sub_network';
// import { MegaMenuSubProfiles } from '#shared/widgets/mega_menu/mega_menu_sub_profiles';
// import { MegaMenuSubStore } from '#shared/widgets/mega_menu/mega_menu_sub_store';
export function MegaMenu() {
// const { pathname } = useLocation();
// const { isActive, hasActiveChild } = useMenu(pathname);
// const homeItem = MENU_MEGA[0];
// const publicProfilesItem = MENU_MEGA[1];
// const myAccountItem = MENU_MEGA[2];
// const networkItem = MENU_MEGA[3];
// const authItem = MENU_MEGA[4];
// const storeItem = MENU_MEGA[5];
// const linkClass = `
// text-sm text-secondary-foreground font-medium
// hover:text-primary hover:bg-transparent
// focus:text-primary focus:bg-transparent
// data-[active=true]:text-primary data-[active=true]:bg-transparent
// data-[state=open]:text-primary data-[state=open]:bg-transparent
// `;
return (
<NavigationMenu>
<NavigationMenuList className="gap-0">
{/* Home Item */}
{/* <NavigationMenuItem>
<NavigationMenuLink asChild>
<Link
to={homeItem.path || '/'}
className={cn(linkClass)}
data-active={isActive(homeItem.path) || undefined}
>
{homeItem.title}
</Link>
</NavigationMenuLink>
</NavigationMenuItem> */}
{/* Public Profiles Item */}
{/* <NavigationMenuItem>
<NavigationMenuTrigger
className={cn(linkClass)}
data-active={hasActiveChild(publicProfilesItem.children) || undefined}
>
{publicProfilesItem.title}
</NavigationMenuTrigger>
<NavigationMenuContent className="p-0">
<MegaMenuSubProfiles items={MENU_MEGA} />
</NavigationMenuContent>
</NavigationMenuItem> */}
{/* My Account Item */}
{/* <NavigationMenuItem>
<NavigationMenuTrigger
className={cn(linkClass)}
data-active={hasActiveChild(myAccountItem.children) || undefined}
>
{myAccountItem.title}
</NavigationMenuTrigger>
<NavigationMenuContent className="p-0">
<MegaMenuSubAccount items={MENU_MEGA} />
</NavigationMenuContent>
</NavigationMenuItem> */}
{/* Network Item */}
{/* <NavigationMenuItem>
<NavigationMenuTrigger
className={cn(linkClass)}
data-active={hasActiveChild(networkItem.children || []) || undefined}
>
{networkItem.title}
</NavigationMenuTrigger>
<NavigationMenuContent className="p-0">
<MegaMenuSubNetwork items={MENU_MEGA} />
</NavigationMenuContent>
</NavigationMenuItem> */}
{/* Store Item */}
{/* <NavigationMenuItem>
<NavigationMenuTrigger
className={cn(linkClass)}
data-active={hasActiveChild(storeItem.children || []) || undefined}
>
{storeItem.title}
</NavigationMenuTrigger>
<NavigationMenuContent className="p-0">
<MegaMenuSubStore items={MENU_MEGA} />
</NavigationMenuContent>
</NavigationMenuItem> */}
{/* Authentication Item */}
{/* <NavigationMenuItem>
<NavigationMenuTrigger
className={cn(linkClass)}
data-active={hasActiveChild(authItem.children) || undefined}
>
{authItem.title}
</NavigationMenuTrigger>
<NavigationMenuContent className="p-0">
<MegaMenuSubAuth items={MENU_MEGA} />
</NavigationMenuContent>
</NavigationMenuItem> */}
</NavigationMenuList>
</NavigationMenu>
);
}
@@ -1,197 +0,0 @@
'use client';
import { LucideIcon } from 'lucide-react';
import { JSX, useCallback } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
// import { MENU_MEGA_MOBILE } from '#app/menu';
import {
AccordionMenu,
AccordionMenuClassNames,
AccordionMenuGroup,
AccordionMenuItem,
// AccordionMenuLabel,
AccordionMenuSub,
AccordionMenuSubContent,
AccordionMenuSubTrigger
} from '#shared/ui/AccordionMenu';
import { Badge } from '#shared/ui/Badge';
export interface MenuItem {
title?: string;
icon?: LucideIcon;
path?: string;
rootPath?: string;
childrenIndex?: number;
heading?: string;
children?: MenuItem[];
disabled?: boolean;
collapse?: boolean;
collapseTitle?: string;
expandTitle?: string;
badge?: string;
separator?: boolean;
}
export type MenuConfig = MenuItem[];
export function MegaMenuMobile() {
const { pathname } = useLocation();
// Memoize matchPath to prevent unnecessary re-renders
const matchPath = useCallback(
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
[pathname]
);
// Global classNames for consistent styling
const classNames: AccordionMenuClassNames = {
root: 'space-y-1',
group: 'gap-px',
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
separator: '',
item: 'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
sub: '',
subTrigger:
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
subContent: 'py-0',
indicator: ''
};
// const buildMenu = (items: MenuConfig): JSX.Element[] => {
// return items.map((item: MenuItem, index: number) => {
// if (item.heading) {
// return buildMenuHeading(item, index);
// } else if (!item.disabled) {
// return buildMenuItemRoot(item, index);
// } else {
// return <></>;
// }
// });
// };
// const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
// if (item.children) {
// return (
// <AccordionMenuSub key={index} value={item.path || `root-${index}`}>
// <AccordionMenuSubTrigger className="text-sm font-medium">
// {item.icon && <item.icon data-slot="accordion-menu-icon" />}
// <div className="flex items-center justify-between grow gap-2">
// <span data-slot="accordion-menu-title">{item.title}</span>
// {item.badge && (
// <Badge variant="secondary" size="sm" className="ms-auto">
// {item.badge}
// </Badge>
// )}
// </div>
// </AccordionMenuSubTrigger>
// <AccordionMenuSubContent
// type="single"
// collapsible
// parentValue={item.path || `root-${index}`}
// className="ps-6"
// >
// <AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
// </AccordionMenuSubContent>
// </AccordionMenuSub>
// );
// } else {
// return (
// <AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
// <Link to={item.path || '#'} className="">
// {item.icon && <item.icon data-slot="accordion-menu-icon" />}
// <div className="flex items-center justify-between grow gap-2">
// <span data-slot="accordion-menu-title">{item.title}</span>
// {item.badge && (
// <Badge variant="secondary" size="sm" className="ms-auto">
// {item.badge}
// </Badge>
// )}
// </div>
// </Link>
// </AccordionMenuItem>
// );
// }
// };
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
return items.map((item: MenuItem, index: number) => {
if (!item.disabled) {
return buildMenuItemChild(item, index, level);
} else {
return <></>;
}
});
};
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
if (item.children) {
return (
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
<AccordionMenuSubTrigger className="text-[13px]">
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
{item.collapse ? (
<span className="text-muted-foreground">
<span className="hidden [[data-state=open]>span>&]:inline">
{item.collapseTitle}
</span>
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
</span>
) : (
item.title
)}
{item.badge && (
<Badge variant="secondary" size="sm" className="ms-auto">
{item.badge}
</Badge>
)}
</AccordionMenuSubTrigger>
<AccordionMenuSubContent
type="single"
collapsible
parentValue={item.path || `child-${level}-${index}`}
className={cn('ps-4', !item.collapse && 'relative')}
>
<AccordionMenuGroup>
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
</AccordionMenuGroup>
</AccordionMenuSubContent>
</AccordionMenuSub>
);
} else {
return (
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
<Link to={item.path || '#'}>
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<div className="flex grow items-center justify-between gap-2">
<span>{item.title}</span>
{item.badge && (
<Badge variant="secondary" size="sm" className="ms-auto">
{item.badge}
</Badge>
)}
</div>
</Link>
</AccordionMenuItem>
);
}
};
// const buildMenuHeading = (item: MenuItem, index: number): JSX.Element => {
// return <AccordionMenuLabel key={index}>{item.heading}</AccordionMenuLabel>;
// };
return (
<div className="flex shrink-0 grow px-5 py-5">
<AccordionMenu
selectedValue={pathname}
matchPath={matchPath}
type="single"
collapsible
classNames={classNames}
>
{/* {buildMenu(MENU_MEGA_MOBILE)} */}
</AccordionMenu>
</div>
);
}
@@ -1,4 +1,3 @@
import { useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils'; import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types'; import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider'; import { useSettings } from '#app/providers/SettingsProvider';
@@ -7,15 +6,12 @@ import { SidebarMenu } from './SidebarMenu';
export function Sidebar() { export function Sidebar() {
const { settings } = useSettings(); const { settings } = useSettings();
const { pathname } = useLocation();
return ( return (
<div <div
className={cn( className={cn(
'sidebar bg-background lg:border-border shrink-0 flex-col items-stretch lg:fixed lg:top-0 lg:bottom-0 lg:z-20 lg:flex lg:border-e', 'sidebar bg-background lg:border-border shrink-0 flex-col items-stretch lg:fixed lg:top-0 lg:bottom-0 lg:z-20 lg:flex lg:border-e',
(settings.layouts[LayoutNames.DEFAULT].sidebarTheme === 'dark' || settings.layouts[LayoutNames.DEFAULT].sidebarTheme === 'dark' && 'dark'
pathname.includes('dark-sidebar')) &&
'dark'
)} )}
> >
<SidebarHeader /> <SidebarHeader />
@@ -1,59 +1,34 @@
import { ChevronFirst } from 'lucide-react'; import { ChevronFirst } from 'lucide-react';
import { Link } from 'react-router-dom';
import { cn } from '@crux-kit/utils'; import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types'; import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider'; import { useSettings } from '#app/providers/SettingsProvider';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button'; import { Button } from '#shared/ui/Button';
import { AppLogo } from '../../shared';
export function SidebarHeader() { export function SidebarHeader() {
const { settings, storeOption } = useSettings(); const { settings, storeOption } = useSettings();
const collapsed = settings.layouts[LayoutNames.DEFAULT].sidebarCollapse;
const handleToggleClick = () => { const handleToggleClick = () => {
storeOption( storeOption(`layouts.${LayoutNames.DEFAULT}.sidebarCollapse`, !collapsed);
`layouts.${LayoutNames.DEFAULT}.sidebarCollapse`,
!settings.layouts[LayoutNames.DEFAULT].sidebarCollapse
);
}; };
return ( return (
<div className="sidebar-header relative hidden shrink-0 items-center justify-between px-3 lg:flex lg:px-6"> <div className="sidebar-header relative hidden shrink-0 items-center px-5 lg:flex">
<Link to="/"> <AppLogo className="size-9 max-w-none" />
<div className="dark:hidden"> <span className="default-logo ml-2 text-2xl font-bold tracking-widest whitespace-nowrap">
{/* <img C.R.U.X
src={toAbsoluteUrl('/media/app/default-logo.svg')} </span>
className="default-logo h-[22px] max-w-none"
alt="Default Logo"
/>
<img
src={toAbsoluteUrl('/media/app/mini-logo.svg')}
className="small-logo h-[22px] max-w-none"
alt="Mini Logo"
/> */}
</div>
<div className="hidden dark:block">
{/* <img
src={toAbsoluteUrl('/media/app/default-logo-dark.svg')}
className="default-logo h-[22px] max-w-none"
alt="Default Dark Logo"
/> */}
<img
src={toAbsoluteUrl('/media/app/mini-logo.svg')}
className="small-logo h-5.5 max-w-none"
alt="Mini Logo"
/>
</div>
</Link>
<Button <Button
onClick={handleToggleClick} onClick={handleToggleClick}
size="sm" size="sm"
mode="icon" mode="icon"
variant="outline" variant="outline"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className={cn( className={cn(
'absolute start-full top-2/4 size-7 -translate-x-2/4 -translate-y-2/4 rtl:translate-x-2/4', 'absolute inset-s-full top-2/4 size-7 -translate-x-2/4 -translate-y-2/4 rtl:translate-x-2/4',
settings.layouts[LayoutNames.DEFAULT].sidebarCollapse collapsed ? 'ltr:rotate-180' : 'rtl:rotate-180'
? 'ltr:rotate-180'
: 'rtl:rotate-180'
)} )}
> >
<ChevronFirst className="size-4!" /> <ChevronFirst className="size-4!" />
@@ -1,180 +1,22 @@
'use client'; import { SidebarAccordionMenu } from '../../shared';
import { JSX, useCallback } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { MenuConfig, MenuItem } from '#app/config/types';
import { useMenuConfig } from '#app/menu';
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
import { Badge } from '#shared/ui/Badge';
export function SidebarMenu() { export function SidebarMenu() {
const { pathname } = useLocation();
const menuConfig = useMenuConfig();
// Memoize matchPath to prevent unnecessary re-renders
const matchPath = useCallback(
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
[pathname]
);
// Global classNames for consistent styling
const classNames: AccordionMenuClassNames = {
root: 'lg:ps-1 space-y-3',
group: 'gap-px',
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
separator: '',
item: 'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
sub: '',
subTrigger:
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
subContent: 'py-0',
indicator: ''
};
const buildMenu = (items: MenuConfig): JSX.Element[] => {
return items.map((item: MenuItem, index: number) => {
if (item.heading) {
return buildMenuHeading(item, index);
} else if (item.disabled) {
return buildMenuItemRootDisabled(item, index);
} else {
return buildMenuItemRoot(item, index);
}
});
};
const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
if (item.children) {
return (
<AccordionMenuSub key={index} value={item.path || `root-${index}`}>
<AccordionMenuSubTrigger className="text-sm font-medium">
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<span data-slot="accordion-menu-title">{item.title}</span>
</AccordionMenuSubTrigger>
<AccordionMenuSubContent
type="single"
collapsible
parentValue={item.path || `root-${index}`}
className="ps-6"
>
<AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
</AccordionMenuSubContent>
</AccordionMenuSub>
);
} else {
return (
<AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
<Link to={item.path || '#'} className="flex grow items-center justify-start gap-2">
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<span data-slot="accordion-menu-title">{item.title}</span>
</Link>
</AccordionMenuItem>
);
}
};
const buildMenuItemRootDisabled = (item: MenuItem, index: number): JSX.Element => {
return (
<AccordionMenuItem key={index} value={`disabled-${index}`} className="text-sm font-medium">
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<span data-slot="accordion-menu-title">{item.title}</span>
{item.disabled && (
<Badge variant="secondary" size="sm" className="ms-auto -me-2.5">
Soon
</Badge>
)}
</AccordionMenuItem>
);
};
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
return items.map((item: MenuItem, index: number) => {
if (item.disabled) {
return buildMenuItemChildDisabled(item, index, level);
} else {
return buildMenuItemChild(item, index, level);
}
});
};
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
if (item.children) {
return (
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
<AccordionMenuSubTrigger className="text-[13px]">
{item.collapse ? (
<span className="text-muted-foreground">
<span className="hidden [[data-state=open]>span>&]:inline">
{item.collapseTitle}
</span>
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
</span>
) : (
item.title
)}
</AccordionMenuSubTrigger>
<AccordionMenuSubContent
type="single"
collapsible
parentValue={item.path || `child-${level}-${index}`}
className={cn(
'ps-4',
!item.collapse && 'relative',
!item.collapse && (level > 0 ? '' : '')
)}
>
<AccordionMenuGroup>
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
</AccordionMenuGroup>
</AccordionMenuSubContent>
</AccordionMenuSub>
);
} else {
return (
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
<Link to={item.path || '#'}>{item.title}</Link>
</AccordionMenuItem>
);
}
};
const buildMenuItemChildDisabled = (
item: MenuItem,
index: number,
level: number = 0
): JSX.Element => {
return (
<AccordionMenuItem
key={index}
value={`disabled-child-${level}-${index}`}
className="text-[13px]"
>
<span data-slot="accordion-menu-title">{item.title}</span>
{item.disabled && (
<Badge variant="secondary" size="sm" className="ms-auto -me-2.5">
Soon
</Badge>
)}
</AccordionMenuItem>
);
};
const buildMenuHeading = (item: MenuItem, index: number): JSX.Element => {
return <AccordionMenuLabel key={index}>{item.heading}</AccordionMenuLabel>;
};
return ( return (
<div className="kt-scrollable-y-hover flex shrink-0 grow px-5 py-5 lg:max-h-[calc(100vh-5.5rem)]"> <div className="kt-scrollable-y-hover flex shrink-0 grow px-5 py-5 lg:max-h-[calc(100vh-5.5rem)]">
<AccordionMenu <SidebarAccordionMenu
selectedValue={pathname} classNames={{
matchPath={matchPath} root: 'lg:ps-1 space-y-3',
type="single" group: 'gap-px',
collapsible label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
classNames={classNames} separator: '',
> item: 'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
{buildMenu(menuConfig)} sub: '',
</AccordionMenu> subTrigger:
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
subContent: 'py-0',
indicator: ''
}}
/>
</div> </div>
); );
} }
@@ -1,86 +0,0 @@
import { ChevronRight } from 'lucide-react';
import { Fragment, ReactNode } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { MenuItem } from '#app/config/types';
import { useMenuConfig } from '#app/menu';
import { useMenu } from '#shared/hooks/useMenu';
export interface ToolbarHeadingProps {
title?: string | ReactNode;
description?: string | ReactNode;
}
function Toolbar({ children }: { children?: ReactNode }) {
return <div className="flex flex-wrap items-center justify-between gap-5 pb-7.5">{children}</div>;
}
function ToolbarActions({ children }: { children?: ReactNode }) {
return <div className="flex items-center gap-2.5">{children}</div>;
}
function ToolbarBreadcrumbs() {
const config = useMenuConfig();
const { pathname } = useLocation();
const { getBreadcrumb, isActive } = useMenu(pathname);
const items: MenuItem[] = getBreadcrumb(config);
if (items.length === 0) {
return null;
}
return (
<div className="[.header_&]:below-lg:hidden mb-2.5 flex items-center gap-1.25 text-xs font-medium lg:mb-0 lg:text-sm">
<div className="breadcrumb flex items-center gap-1">
{items.map((item, index) => {
const isLast = index === items.length - 1;
const active = item.path ? isActive(item.path) : false;
return (
<Fragment key={index}>
{item.path ? (
<Link
to={item.path}
className={cn(
'flex items-center gap-1',
active ? 'text-mono' : 'text-muted-foreground hover:text-primary'
)}
>
{item.title}
</Link>
) : (
<span className={cn(isLast ? 'text-mono' : 'text-muted-foreground')}>
{item.title}
</span>
)}
{!isLast && <ChevronRight className="muted-foreground size-3.5" />}
</Fragment>
);
})}
</div>
</div>
);
}
function ToolbarHeading({ title = '', description }: ToolbarHeadingProps) {
const { pathname } = useLocation();
const { getCurrentItem } = useMenu(pathname);
const config = useMenuConfig();
const item = getCurrentItem(config);
return (
<div className="flex flex-col justify-center gap-2">
<h1 className="text-mono text-xl leading-none font-medium">
{title || item?.title || 'Untitled'}
</h1>
{description && (
<div className="text-muted-foreground flex items-center gap-2 text-sm font-normal">
{description}
</div>
)}
</div>
);
}
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
@@ -6,42 +6,40 @@ import { Outlet } from 'react-router-dom';
import { useIsMobile } from '@crux-kit/hooks'; import { useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types'; import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider'; import { useSettings } from '#app/providers/SettingsProvider';
import { Footer } from './components/Footer'; import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar'; import { Sidebar } from './components/Sidebar';
export function DefaultLayout() { export function DefaultLayout() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { settings, setOption } = useSettings(); const { settings } = useSettings();
useLayoutSettings(LayoutNames.DEFAULT);
const { showToolbar, showFooter } = useLayoutChrome();
const sidebarCollapse = settings.layouts[LayoutNames.DEFAULT].sidebarCollapse;
useEffect(() => { useEffect(() => {
const bodyClass = document.body.classList; const bodyClass = document.body.classList;
if (settings.layouts[LayoutNames.DEFAULT].sidebarCollapse) { if (sidebarCollapse) {
bodyClass.add('sidebar-collapse'); bodyClass.add('sidebar-collapse');
} else { } else {
bodyClass.remove('sidebar-collapse'); bodyClass.remove('sidebar-collapse');
} }
}, [settings]); // Runs only on settings update }, [sidebarCollapse]);
useEffect(() => {
// Set current layout
setOption('layout', LayoutNames.DEFAULT);
}, [setOption]);
useEffect(() => { useEffect(() => {
const bodyClass = document.body.classList; const bodyClass = document.body.classList;
// Add a class to the body element
bodyClass.add(LayoutNames.DEFAULT); bodyClass.add(LayoutNames.DEFAULT);
bodyClass.add('sidebar-fixed'); bodyClass.add('sidebar-fixed');
bodyClass.add('header-fixed'); bodyClass.add('header-fixed');
const timer = setTimeout(() => { const timer = setTimeout(() => {
bodyClass.add('layout-initialized'); bodyClass.add('layout-initialized');
}, 1000); // 1000 milliseconds }, 1000);
// Remove the class when the component is unmounted
return () => { return () => {
bodyClass.remove(LayoutNames.DEFAULT); bodyClass.remove(LayoutNames.DEFAULT);
bodyClass.remove('sidebar-fixed'); bodyClass.remove('sidebar-fixed');
@@ -50,7 +48,7 @@ export function DefaultLayout() {
bodyClass.remove('layout-initialized'); bodyClass.remove('layout-initialized');
clearTimeout(timer); clearTimeout(timer);
}; };
}, []); // Runs only once on mount }, []);
return ( return (
<> <>
@@ -60,10 +58,15 @@ export function DefaultLayout() {
<Header /> <Header />
<main className="grow pt-5" role="content"> <main className="grow pt-5" role="content">
{showToolbar && (
<Toolbar>
<ToolbarHeading />
</Toolbar>
)}
<Outlet /> <Outlet />
</main> </main>
<Footer /> {showFooter && <AppFooter />}
</div> </div>
</> </>
); );
@@ -0,0 +1,25 @@
# Horizontal Menu layout
**Metronic 9 reference:** [Demo 2](https://keenthemes.com/metronic/tailwind/react/demo2)
A header carrying the logo and the account menu, with the application menu on a horizontal menubar
underneath it. The header becomes sticky and shrinks once the page is scrolled past
`layouts.horizontal-menu-layout.headerStickyOffset`.
## Composition
| Piece | Source |
| ----------------------- | --------------------------------------------- |
| Navigation menubar | `NavMenubar` from `../shared` |
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
| Footer | `AppFooter` from `../shared` |
| Account menu | `UserAvatarMenu` from `../shared` |
Layout-specific: `Header`, `HeaderLogo`, `Navbar`.
## Notes
- Below `lg` the menubar is replaced by an accordion in a sheet, since a horizontal menubar cannot
hold the full menu on a narrow viewport.
- The `data-sticky-header` body attribute is removed on unmount so the next layout starts clean.
@@ -1,42 +0,0 @@
import { generalSettings } from '#app/config/general';
import { Container } from '#shared/widgets/common/Container';
export function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="footer">
<Container>
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
<span className="text-muted-foreground">{currentYear} &copy;</span>
<a
href="https://keenthemes.com"
target="_blank"
className="text-secondary-foreground hover:text-primary"
>
Keenthemes Inc.
</a>
</div>
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
Docs
</a>
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
Purchase
</a>
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
FAQ
</a>
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
Support
</a>
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
License
</a>
</nav>
</div>
</Container>
</footer>
);
}
@@ -1,44 +1,45 @@
import { useEffect, useState } from 'react'; // Add useState to the imports import { useEffect, useState } from 'react';
import { useScrollPosition } from '@crux-kit/hooks'; import { useScrollPosition } from '@crux-kit/hooks';
import { cn } from '@crux-kit/utils'; import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types'; import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider'; import { useSettings } from '#app/providers/SettingsProvider';
import { Container } from '#shared/widgets/common/Container'; import { Container } from '#shared/widgets/common/Container';
import { UserAvatarMenu } from '../../shared';
import { HeaderLogo } from './HeaderLogo'; import { HeaderLogo } from './HeaderLogo';
import { HeaderTopbar } from './HeaderTopbar';
export function Header() { export function Header() {
const { settings } = useSettings(); const { settings } = useSettings();
const scrollPosition = useScrollPosition(); const scrollPosition = useScrollPosition();
const [headerStickyOn, setHeaderStickyOn] = useState(false); const [headerStickyOn, setHeaderStickyOn] = useState(false);
useEffect(() => { const { headerSticky, headerStickyOffset } = settings.layouts[LayoutNames.HORIZONTAL_MENU];
const isSticky =
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
setHeaderStickyOn(isSticky);
}, [scrollPosition, settings]);
useEffect(() => { useEffect(() => {
if (headerStickyOn === true) { setHeaderStickyOn(scrollPosition > headerStickyOffset);
}, [scrollPosition, headerStickyOffset]);
useEffect(() => {
if (headerStickyOn) {
document.body.setAttribute('data-sticky-header', 'on'); document.body.setAttribute('data-sticky-header', 'on');
} else { } else {
document.body.removeAttribute('data-sticky-header'); document.body.removeAttribute('data-sticky-header');
} }
return () => document.body.removeAttribute('data-sticky-header');
}, [headerStickyOn]); }, [headerStickyOn]);
return ( return (
<header <header
className={cn( className={cn(
'flex h-(--header-height) shrink-0 items-center transition-[height] in-data-[sticky-header=on]:pe-(--removed-body-scroll-bar-size,0px)', 'flex h-(--header-height) shrink-0 items-center transition-[height] in-data-[sticky-header=on]:pe-(--removed-body-scroll-bar-size,0px)',
settings.layouts[LayoutNames.HORIZONTAL_MENU].headerSticky && headerSticky &&
headerStickyOn && headerStickyOn &&
'bg-background/70 fixed start-0 end-0 top-0 z-10 shadow-xs backdrop-blur-md transition-[height]' 'bg-background/70 fixed start-0 end-0 top-0 z-10 shadow-xs backdrop-blur-md transition-[height]'
)} )}
> >
<Container className="flex items-center justify-between lg:gap-4"> <Container className="flex items-center justify-between lg:gap-4">
<HeaderLogo /> <HeaderLogo />
<HeaderTopbar /> <UserAvatarMenu />
</Container> </Container>
</header> </header>
); );
@@ -1,71 +1,41 @@
// import { useEffect, useState } from 'react'; import { Menu } from 'lucide-react';
// import { ChevronDown } from 'lucide-react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
// import { MENU_ROOT } from '#app/menu'; import { useIsMobile } from '@crux-kit/hooks';
// import { import { Button } from '#shared/ui/Button';
// DropdownMenu, import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
// DropdownMenuContent, import { AppLogo, AppWordmark, SidebarAccordionMenu } from '../../shared';
// DropdownMenuItem,
// DropdownMenuTrigger,
// } from '#shared/ui/dropdown_menu';
import { toAbsoluteUrl } from '#shared/lib/helpers';
// import { cn } from '@crux-kit/utils';
export function HeaderLogo() { export function HeaderLogo() {
// const { pathname } = useLocation(); const { pathname } = useLocation();
// const [selectedMenuItem, setSelectedMenuItem] = useState(MENU_ROOT[1]); const isMobile = useIsMobile();
const [isSheetOpen, setIsSheetOpen] = useState(false);
// useEffect(() => { useEffect(() => {
// MENU_ROOT.forEach((item) => { setIsSheetOpen(false);
// if (item.rootPath && pathname.includes(item.rootPath)) { }, [pathname]);
// setSelectedMenuItem(item);
// }
// });
// }, [pathname]);
return ( return (
<div className="flex items-center gap-2 lg:gap-5 2xl:-ms-15"> <div className="flex items-center gap-2 lg:gap-5 2xl:-ms-15">
{/* Logo Section */} <AppLogo className="size-10.5" />
<Link to="/" className="shrink-0">
<img
src={toAbsoluteUrl('/media/app/mini-logo-circle.svg')}
className="min-h-10.5 dark:hidden"
alt="logo"
/>
<img
src={toAbsoluteUrl('/media/app/mini-logo-circle-dark.svg')}
className="hidden min-h-10.5 dark:inline-block"
alt="logo"
/>
</Link>
{/* Menu Section */} <AppWordmark className="text-accent-foreground hidden text-base md:block" />
<div className="flex items-center gap-3">
<h3 className="text-accent-foreground hidden text-base md:block">Metronic Team</h3>
<span className="text-muted-foreground hidden text-sm font-medium md:inline">/</span>
{/* <DropdownMenu> {isMobile && (
<DropdownMenuTrigger className="cursor-pointer text-mono font-medium flex items-center gap-2"> <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
{selectedMenuItem.title} <SheetTrigger asChild>
<ChevronDown className="size-4 text-muted-foreground" /> <Button variant="ghost" mode="icon">
</DropdownMenuTrigger> <Menu />
<DropdownMenuContent sideOffset={10} side="bottom" align="start"> </Button>
{MENU_ROOT.map((item, index) => ( </SheetTrigger>
<DropdownMenuItem <SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
key={index} <SheetHeader className="space-y-0 p-0" />
asChild <SheetBody className="overflow-y-auto py-5">
className={cn(item === selectedMenuItem && 'bg-accent')} <SidebarAccordionMenu />
> </SheetBody>
<Link to={item.path || ''}> </SheetContent>
{item.icon && <item.icon />} </Sheet>
{item.title} )}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu> */}
</div>
</div> </div>
); );
} }
@@ -1,46 +0,0 @@
// import { MessageCircleMore, MessageSquareDot, Search } from 'lucide-react';
// import { Button } from '#shared/ui/button';
import { toAbsoluteUrl } from '#shared/lib/helpers';
// import { SearchDialog } from '#shared/partials/dialogs/search/search-dialog';
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
// import { NotificationsSheet } from '#shared/partials/topbar/notifications-sheet';
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
export function HeaderTopbar() {
return (
<div className="flex items-center gap-3">
<div className="flex items-center gap-3">
{/* <SearchDialog
trigger={
<Button variant="ghost" mode="icon" shape="circle" className="size-9">
<Search className="size-4.5!" />
</Button>
}
/>
<ChatSheet
trigger={
<Button variant="ghost" mode="icon" shape="circle" className="size-9">
<MessageCircleMore className="size-4.5!" />
</Button>
}
/>
<NotificationsSheet
trigger={
<Button variant="ghost" mode="icon" size="sm" shape="circle" className="size-9">
<MessageSquareDot className="size-4.5!" />
</Button>
}
/> */}
</div>
<UserDropdownMenu
trigger={
<img
className="size-9 shrink-0 cursor-pointer justify-center rounded-full border border-gray-500"
src={toAbsoluteUrl('/media/avatars/gray/5.png')}
alt=""
/>
}
/>
</div>
);
}
@@ -1,13 +1,18 @@
import { useIsMobile } from '@crux-kit/hooks';
import { Container } from '#shared/widgets/common/Container'; import { Container } from '#shared/widgets/common/Container';
import { NavbarLinks } from './NavbarLinks'; import { NavMenubar } from '../../shared';
import { NavbarMenu } from './NavbarMenu';
export function Navbar() { export function Navbar() {
const isMobile = useIsMobile();
if (isMobile) {
return null;
}
return ( return (
<div className="border-border mb-5 border-b pb-5 lg:mb-10 lg:pb-0"> <div className="border-border mb-5 border-b pb-5 lg:mb-10 lg:pb-0">
<Container className="flex flex-wrap items-center justify-between gap-2"> <Container className="flex flex-wrap items-center justify-between gap-2">
<NavbarMenu /> <NavMenubar />
<NavbarLinks />
</Container> </Container>
</div> </div>
); );
@@ -1,15 +0,0 @@
export function NavbarLinks() {
return (
<div className="flex items-center gap-5 text-sm lg:pb-1">
<a className="hover:text-primary" href="https://www.youtube.com/c/KeenThemesTuts/videos">
Videos
</a>
<a className="hover:text-primary" href="https://keenthemes.com/metronic/tailwind/docs/">
User Guides
</a>
<a className="hover:text-primary" href="https://devs.keenthemes.com">
Support
</a>
</div>
);
}
@@ -1,121 +0,0 @@
import { ChevronDown } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { MenuConfig } from '#app/config/types';
import { useMenuConfig } from '#app/menu';
import { useMenu } from '#shared/hooks/useMenu';
import { Menubar, MenubarContent, MenubarItem, MenubarMenu, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger } from '#shared/ui/MenuBar';
export function NavbarMenu() {
const { pathname } = useLocation();
const menuConfig = useMenuConfig();
let navbarMenu;
if (pathname.includes('/public-profile/')) {
navbarMenu = menuConfig?.[2];
} else if (pathname.includes('/network/')) {
navbarMenu = menuConfig?.[4];
} else if (pathname.includes('/store-client/')) {
navbarMenu = menuConfig?.[0];
} else if (pathname.includes('/authentication/')) {
navbarMenu = menuConfig?.[5];
} else {
navbarMenu = menuConfig?.[3];
}
const { isActive, hasActiveChild } = useMenu(pathname);
const buildMenu = (items: MenuConfig) => {
return items.map((item, index) => {
if (item.children) {
return (
<MenubarMenu key={index}>
<MenubarTrigger
className={cn(
'text-secondary-foreground flex items-center gap-1.5 px-3 py-3.5 text-sm text-nowrap',
'rounded-none border-b-2 border-transparent bg-transparent!',
'hover:text-mono hover:bg-transparent',
'focus:text-mono focus:bg-transparent',
'data-[state=open]:text-mono data-[state=open]:bg-transparent',
'data-[here=true]:text-mono data-[here=true]:border-mono'
)}
data-active={isActive(item.path) || undefined}
data-here={hasActiveChild(item.children) || undefined}
>
{item.title}
<ChevronDown className="ms-auto size-3.5" />
</MenubarTrigger>
<MenubarContent className="min-w-43.75" sideOffset={0}>
{buildSubMenu(item.children)}
</MenubarContent>
</MenubarMenu>
);
} else {
return (
<MenubarMenu key={index}>
<MenubarTrigger
asChild
className={cn(
'text-secondary-foreground flex items-center px-3 py-3.5 text-sm text-nowrap',
'rounded-none border-b-2 border-transparent bg-transparent!',
'hover:text-mono hover:bg-transparent',
'focus:text-mono focus:bg-transparent',
'data-[active=true]:text-mono data-[active=true]:border-mono'
)}
>
<Link
to={item.path || ''}
data-active={isActive(item.path) || undefined}
data-here={hasActiveChild(item.children) || undefined}
>
{item.title}
</Link>
</MenubarTrigger>
</MenubarMenu>
);
}
});
};
const buildSubMenu = (items: MenuConfig) => {
return items.map((item, index) => {
if (item.children) {
return (
<MenubarSub key={index}>
<MenubarSubTrigger
data-active={isActive(item.path) || undefined}
data-here={hasActiveChild(item.children) || undefined}
>
<span>{item.title}</span>
</MenubarSubTrigger>
<MenubarSubContent className="min-w-43.75">
{buildSubMenu(item.children)}
</MenubarSubContent>
</MenubarSub>
);
} else {
return (
<MenubarItem
key={index}
asChild
data-active={isActive(item.path) || undefined}
data-here={hasActiveChild(item.children) || undefined}
>
<Link to={item.path || ''}>{item.title}</Link>
</MenubarItem>
);
}
});
};
return (
<div className="grid">
<div className="kt-scrollable-x-auto">
<Menubar className="flex h-auto items-stretch border-none bg-transparent p-0">
{buildMenu(navbarMenu.children as MenuConfig)}
</Menubar>
</div>
</div>
);
}
@@ -1,86 +0,0 @@
import { ChevronRight } from 'lucide-react';
import { Fragment, ReactNode } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { MenuItem } from '#app/config/types';
import { useMenuConfig } from '#app/menu';
import { useMenu } from '#shared/hooks/useMenu';
import { Container } from '#shared/widgets/common/Container';
export interface ToolbarHeadingProps {
title?: string | ReactNode;
description?: string | ReactNode;
}
function Toolbar({ children }: { children?: ReactNode }) {
return (
<div className="mb-5 lg:mb-10">
<Container className="flex flex-wrap items-center justify-between gap-5">
{children}
</Container>
</div>
);
}
function ToolbarActions({ children }: { children?: ReactNode }) {
return <div className="flex items-center gap-2.5">{children}</div>;
}
function ToolbarBreadcrumbs() {
const menuConfig = useMenuConfig();
const { pathname } = useLocation();
const { getBreadcrumb, isActive } = useMenu(pathname);
const items: MenuItem[] = getBreadcrumb(menuConfig);
if (items.length === 0) {
return null;
}
return (
<div className="[.header_&]:below-lg:hidden mb-2.5 flex items-center gap-1.25 text-xs font-medium lg:mb-0 lg:text-sm">
<div className="breadcrumb flex items-center gap-1">
{items.map((item, index) => {
const isLast = index === items.length - 1;
const active = item.path ? isActive(item.path) : false;
return (
<Fragment key={index}>
{item.path ? (
<Link
to={item.path}
className={cn(
'flex items-center gap-1',
active ? 'text-mono' : 'text-muted-foreground hover:text-primary'
)}
>
{item.title}
</Link>
) : (
<span className={cn(isLast ? 'text-mono' : 'text-muted-foreground')}>
{item.title}
</span>
)}
{!isLast && <ChevronRight className="muted-foreground size-3.5" />}
</Fragment>
);
})}
</div>
</div>
);
}
const ToolbarHeading = ({ title = '' }: ToolbarHeadingProps) => {
const { pathname } = useLocation();
const { getCurrentItem } = useMenu(pathname);
const menuConfig = useMenuConfig();
const item = getCurrentItem(menuConfig);
return (
<div className="flex flex-wrap items-center gap-1 lg:gap-5">
<h1 className="text-mono text-lg font-medium">{title || item?.title}</h1>
<ToolbarBreadcrumbs />
</div>
);
};
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
@@ -1,30 +1,16 @@
// DEMO 2 // DEMO 2
// https://keenthemes.com/metronic/tailwind/react/demo2 // https://keenthemes.com/metronic/tailwind/react/demo2
import { addDays, format } from 'date-fns'; import { Outlet } from 'react-router-dom';
import { CalendarDays } from 'lucide-react';
import { useEffect, useState } from 'react';
import { DateRange } from 'react-day-picker';
import { Outlet, useLocation } from 'react-router-dom';
import { useBodyClass } from '@crux-kit/hooks'; import { useBodyClass } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types'; import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider'; import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
import { Button } from '#shared/ui/Button';
import { Calendar } from '#shared/ui/Calendar';
import { Popover, PopoverContent, PopoverTrigger } from '#shared/ui/Popover';
import { Footer } from './components/Footer';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { Navbar } from './components/Navbar'; import { Navbar } from './components/Navbar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export function HorizontalMenuLayout() { export function HorizontalMenuLayout() {
const { pathname } = useLocation(); useLayoutSettings(LayoutNames.HORIZONTAL_MENU);
const { setOption } = useSettings(); const { showToolbar, showFooter } = useLayoutChrome();
useEffect(() => {
// Set current layout
setOption('layout', LayoutNames.HORIZONTAL_MENU);
}, [setOption]);
useBodyClass(` useBodyClass(`
[--header-height-default:100px] [--header-height-default:100px]
@@ -32,11 +18,6 @@ export function HorizontalMenuLayout() {
[--header-height:var(--header-height-default)] [--header-height:var(--header-height-default)]
`); `);
const [date, setDate] = useState<DateRange | undefined>({
from: new Date(2025, 0, 20),
to: addDays(new Date(2025, 0, 20), 20)
});
return ( return (
<> <>
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height-default)"> <div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height-default)">
@@ -45,45 +26,15 @@ export function HorizontalMenuLayout() {
<Navbar /> <Navbar />
<main className="grow" role="content"> <main className="grow" role="content">
{!pathname.includes('/public-profile/') && ( {showToolbar && (
<Toolbar> <Toolbar>
<ToolbarHeading /> <ToolbarHeading />
<ToolbarActions>
<Popover>
<PopoverTrigger asChild>
<Button mode="input" variant="outline">
<CalendarDays />
{date?.from ? (
date.to ? (
<>
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
</>
) : (
format(date.from, 'LLL dd, y')
)
) : (
<span>Pick a date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
initialFocus
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={setDate}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</ToolbarActions>
</Toolbar> </Toolbar>
)} )}
<Outlet /> <Outlet />
</main> </main>
<Footer /> {showFooter && <AppFooter />}
</div> </div>
</> </>
); );
@@ -0,0 +1,26 @@
# Compact Sidebar layout
**Metronic 9 reference:** [Demo 3](https://keenthemes.com/metronic/tailwind/react/demo3)
A narrow icon rail listing the top-level menu sections, with the selected section's children on a
horizontal navbar above the content card.
## Composition
| Piece | Source |
| --------------------------- | ------------------------------------------------------------ |
| Section chooser (icon rail) | `useAppMenu` + `firstMenuPath` from `../shared` |
| Section navbar | `NavMenubar` from `../shared`, fed by `useActiveMenuSection` |
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
| Footer | `AppFooter` from `../shared` |
| Account menu | `UserAvatarMenu` from `../shared` |
Layout-specific: `Header`, `HeaderLogo`, `Sidebar`, `SidebarMenu`, `Navbar`.
## Notes
- A section with no children renders no navbar, so a single-page section such as the dashboard fills
the card.
- This layout sets `container` to `fluid`; the setting is per-layout, so other layouts keep theirs.
- Body variables: `--header-height: 58px`, `--sidebar-width: 58px`, `--navbar-height: 56px`.
@@ -1,42 +0,0 @@
import { generalSettings } from '#app/config/general';
import { Container } from '#shared/widgets/common/Container';
export function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="footer">
<Container>
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
<span className="text-muted-foreground">{currentYear} &copy;</span>
<a
href="https://keenthemes.com"
target="_blank"
className="text-secondary-foreground hover:text-primary"
>
Keenthemes Inc.
</a>
</div>
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
Docs
</a>
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
Purchase
</a>
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
FAQ
</a>
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
Support
</a>
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
License
</a>
</nav>
</div>
</Container>
</footer>
);
}
@@ -1,13 +1,13 @@
import { Container } from '#shared/widgets/common/Container'; import { Container } from '#shared/widgets/common/Container';
import { UserAvatarMenu } from '../../shared';
import { HeaderLogo } from './HeaderLogo'; import { HeaderLogo } from './HeaderLogo';
import { HeaderTopbar } from './HeaderTopbar';
export function Header() { export function Header() {
return ( return (
<header className="bg-muted fixed top-0 right-0 left-0 z-10 flex h-(--header-height) shrink-0 items-center"> <header className="bg-muted fixed top-0 right-0 left-0 z-10 flex h-(--header-height) shrink-0 items-center">
<Container className="flex items-stretch justify-between px-5 lg:gap-4 lg:ps-0"> <Container className="flex items-stretch justify-between px-5 lg:gap-4 lg:ps-0">
<HeaderLogo /> <HeaderLogo />
<HeaderTopbar /> <UserAvatarMenu />
</Container> </Container>
</header> </header>
); );
@@ -1,87 +1,39 @@
import { Menu } from 'lucide-react'; import { Menu } from 'lucide-react';
import { Link } from 'react-router-dom'; import { useEffect, useState } from 'react';
import { toAbsoluteUrl } from '#shared/lib/helpers'; import { useLocation } from 'react-router-dom';
// import { cn } from '@crux-kit/utils';
import { Button } from '#shared/ui/Button'; import { Button } from '#shared/ui/Button';
// import {
// DropdownMenu,
// DropdownMenuContent,
// DropdownMenuItem,
// DropdownMenuTrigger,
// } from '#shared/ui/dropdown_menu';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet'; import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { SidebarMenu } from './SidebarMenu'; import { AppLogo, AppWordmark, SidebarAccordionMenu } from '../../shared';
export function HeaderLogo() { export function HeaderLogo() {
// const { pathname } = useLocation(); const { pathname } = useLocation();
// const [selectedMenuItem, setSelectedMenuItem] = useState(MENU_ROOT[1]); const [isSheetOpen, setIsSheetOpen] = useState(false);
// useEffect(() => { useEffect(() => {
// MENU_ROOT.forEach((item) => { setIsSheetOpen(false);
// if (item.rootPath && pathname.includes(item.rootPath)) { }, [pathname]);
// setSelectedMenuItem(item);
// }
// });
// }, [pathname]);
return ( return (
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
{/* Logo */}
<div className="flex shrink-0 items-center justify-center lg:w-(--sidebar-width)"> <div className="flex shrink-0 items-center justify-center lg:w-(--sidebar-width)">
<Sheet> <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="ghost" mode="icon" className="-ms-2 lg:hidden"> <Button variant="ghost" mode="icon" className="-ms-2 lg:hidden">
<Menu className="size-4!" /> <Menu className="size-4!" />
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent className="w-(--sidebar-width) gap-0 p-0" side="left" close={false}> <SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" /> <SheetHeader className="space-y-0 p-0" />
<SheetBody className="overflow-y-auto p-0"> <SheetBody className="overflow-y-auto py-5">
<SidebarMenu /> <SidebarAccordionMenu />
</SheetBody> </SheetBody>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<Link to="/" className="mx-1"> <AppLogo className="size-6" linkClassName="mx-1" />
<img
src={toAbsoluteUrl('/media/app/mini-logo-primary.svg')}
className="min-h-6 dark:hidden"
alt="logo"
/>
<img
src={toAbsoluteUrl('/media/app/mini-logo-primary-dark.svg')}
className="hidden min-h-6 dark:inline-block"
alt="logo"
/>
</Link>
</div> </div>
{/* Menu Section */} <AppWordmark className="text-accent-foreground hidden text-base md:block" />
<div className="flex items-center gap-3">
<h3 className="text-accent-foreground hidden text-base md:block">Metronic Team</h3>
<span className="text-muted-foreground hidden text-sm font-medium md:inline">/</span>
{/* <DropdownMenu>
<DropdownMenuTrigger className="cursor-pointer text-mono font-medium flex items-center gap-2">
{selectedMenuItem.title}
<ChevronDown className="size-3.5! text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} side="bottom" align="start">
{MENU_ROOT.map((item, index) => (
<DropdownMenuItem
key={index}
asChild
className={cn(item === selectedMenuItem && 'bg-accent')}
>
<Link to={item.path || ''}>
{item.icon && <item.icon />}
{item.title}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu> */}
</div>
</div> </div>
); );
} }
@@ -1,56 +0,0 @@
// import { LayoutGrid, MessageCircleMore, MessageSquareDot, Search } from 'lucide-react';
import { Link } from 'react-router-dom';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button';
// import { SearchDialog } from '#shared/partials/dialogs/search/search-dialog';
// import { AppsDropdownMenu } from '#shared/partials/topbar/apps-dropdown-menu';
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
// import { NotificationsSheet } from '#shared/partials/topbar/notifications-sheet';
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
export function HeaderTopbar() {
return (
<div className="flex items-center gap-2 lg:gap-3.5">
<Button asChild className="hidden md:inline-flex">
<Link to="account/home/get-started">Get Started</Link>
</Button>
{/* <SearchDialog
trigger={
<Button variant="ghost" mode="icon" shape="circle" className="hover:[&_svg]:text-primary">
<Search className="size-4.5!" />
</Button>
}
/>
<ChatSheet
trigger={
<Button variant="ghost" mode="icon" shape="circle" className="hover:[&_svg]:text-primary">
<MessageCircleMore className="size-4.5!" />
</Button>
}
/>
<AppsDropdownMenu
trigger={
<Button variant="ghost" mode="icon" shape="circle" className="hover:[&_svg]:text-primary">
<LayoutGrid className="size-4.5!" />
</Button>
}
/>
<NotificationsSheet
trigger={
<Button variant="ghost" mode="icon" shape="circle" className="hover:[&_svg]:text-primary">
<MessageSquareDot className="size-4.5!" />
</Button>
}
/> */}
<UserDropdownMenu
trigger={
<img
className="border-input size-9 shrink-0 cursor-pointer rounded-full border-2"
src={toAbsoluteUrl('/media/avatars/gray/5.png')}
alt="User Avatar"
/>
}
/>
</div>
);
}
@@ -1,14 +1,20 @@
import { Container } from '#shared/widgets/common/Container'; import { Container } from '#shared/widgets/common/Container';
import { NavbarLinks } from './NavbarLinks'; import { NavMenubar, useActiveMenuSection } from '../../shared';
import { NavbarMenu } from './NavbarMenu';
/** The children of the section selected in the compact sidebar. */
export function Navbar() { export function Navbar() {
const section = useActiveMenuSection();
const items = section?.children ?? [];
if (items.length === 0) {
return null;
}
return ( return (
<div className="bg-muted start-(--sidebar-width) end-5 top-(--header-height) z-5 mx-5 flex h-(--navbar-height) items-stretch lg:fixed lg:mx-0"> <div className="bg-muted start-(--sidebar-width) end-5 top-(--header-height) z-5 mx-5 flex h-(--navbar-height) items-stretch lg:fixed lg:mx-0">
<div className="border-border bg-background flex grow items-stretch rounded-t-xl border"> <div className="border-border bg-background flex grow items-stretch rounded-t-xl border">
<Container className="flex items-stretch justify-between gap-5"> <Container className="flex items-stretch justify-between gap-5">
<NavbarMenu /> <NavMenubar items={items} />
<NavbarLinks />
</Container> </Container>
</div> </div>
</div> </div>
@@ -1,46 +0,0 @@
import { addDays, format } from 'date-fns';
import { CalendarDays } from 'lucide-react';
import { useState } from 'react';
import { DateRange } from 'react-day-picker';
import { Button } from '#shared/ui/Button';
import { Calendar } from '#shared/ui/Calendar';
import { Popover, PopoverContent, PopoverTrigger } from '#shared/ui/Popover';
export function NavbarLinks() {
const [date, setDate] = useState<DateRange | undefined>({
from: new Date(2025, 0, 20),
to: addDays(new Date(2025, 0, 20), 20)
});
return (
<div className="flex items-center">
<Popover>
<PopoverTrigger asChild>
<Button id="date" variant="outline" mode="input">
<CalendarDays />
{date?.from ? (
date.to ? (
<span>
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
</span>
) : (
format(date.from, 'LLL dd, y')
)
) : (
<span>Pick a date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={setDate}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
);
}
@@ -1,121 +0,0 @@
import { ChevronDown } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { MenuConfig } from '#app/config/types';
import { useMenuConfig } from '#app/menu';
import { useMenu } from '#shared/hooks/useMenu';
import { Menubar, MenubarContent, MenubarItem, MenubarMenu, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger } from '#shared/ui/MenuBar';
export function NavbarMenu() {
const { pathname } = useLocation();
const menuConfig = useMenuConfig();
let navbarMenu;
if (pathname.includes('/public-profile/')) {
navbarMenu = menuConfig?.[2];
} else if (pathname.includes('/network/')) {
navbarMenu = menuConfig?.[4];
} else if (pathname.includes('/store-client/')) {
navbarMenu = menuConfig?.[0];
} else if (pathname.includes('/authentication/')) {
navbarMenu = menuConfig?.[5];
} else {
navbarMenu = menuConfig?.[3];
}
const { isActive, hasActiveChild } = useMenu(pathname);
const buildMenu = (items: MenuConfig) => {
return items.map((item, index) => {
if (item.children) {
return (
<MenubarMenu key={index}>
<MenubarTrigger
className={cn(
'text-secondary-foreground flex items-center gap-1 px-0 py-3.5 text-sm text-nowrap',
'rounded-none border-b-2 border-transparent bg-transparent!',
'hover:text-mono hover:bg-transparent',
'focus:text-mono focus:bg-transparent',
'data-[state=open]:text-mono data-[state=open]:bg-transparent',
'data-[here=true]:text-mono data-[here=true]:border-mono'
)}
data-active={isActive(item.path) || undefined}
data-here={hasActiveChild(item.children) || undefined}
>
{item.title}
<ChevronDown className="ms-auto size-3.5!" />
</MenubarTrigger>
<MenubarContent className="min-w-43.75" sideOffset={0}>
{buildSubMenu(item.children)}
</MenubarContent>
</MenubarMenu>
);
} else {
return (
<MenubarMenu key={index}>
<MenubarTrigger
asChild
className={cn(
'text-secondary-foreground flex items-center px-3 py-3.5 text-sm text-nowrap',
'rounded-none border-b-2 border-transparent bg-transparent!',
'hover:text-mono hover:bg-transparent',
'focus:text-mono focus:bg-transparent',
'data-[active=true]:text-mono data-[active=true]:border-mono'
)}
>
<Link
to={item.path || ''}
data-active={isActive(item.path) || undefined}
data-here={hasActiveChild(item.children) || undefined}
>
{item.title}
</Link>
</MenubarTrigger>
</MenubarMenu>
);
}
});
};
const buildSubMenu = (items: MenuConfig) => {
return items.map((item, index) => {
if (item.children) {
return (
<MenubarSub key={index}>
<MenubarSubTrigger
data-active={isActive(item.path) || undefined}
data-here={hasActiveChild(item.children) || undefined}
>
<span>{item.title}</span>
</MenubarSubTrigger>
<MenubarSubContent className="min-w-43.75">
{buildSubMenu(item.children)}
</MenubarSubContent>
</MenubarSub>
);
} else {
return (
<MenubarItem
key={index}
asChild
data-active={isActive(item.path) || undefined}
data-here={hasActiveChild(item.children) || undefined}
>
<Link to={item.path || ''}>{item.title}</Link>
</MenubarItem>
);
}
});
};
return (
<div className="grid">
<div className="kt-scrollable-x-auto flex items-stretch">
<Menubar className="flex h-auto items-stretch gap-5 space-x-0 border-none bg-transparent p-0">
{buildMenu(navbarMenu.children as MenuConfig)}
</Menubar>
</div>
</div>
);
}
@@ -1,99 +1,52 @@
import { BarChart3, Bell, CheckSquare, Code, HelpCircle, MessageSquare, Settings, Shield, UserCircle, Users } from 'lucide-react'; import { Link, useLocation } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { cn } from '@crux-kit/utils'; import { cn } from '@crux-kit/utils';
import { useMenu } from '#shared/hooks/useMenu';
import { Button } from '#shared/ui/Button'; import { Button } from '#shared/ui/Button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip';
import { firstMenuPath, useAppMenu } from '../../shared';
export interface Item { /**
icon: React.ComponentType<{ className?: string }>; * The section chooser: one icon per top-level menu entry. Its children are shown by the navbar, so
path: string; * this rail never needs to expand.
title: string; */
newTab?: boolean;
active?: boolean;
}
export function SidebarMenu() { export function SidebarMenu() {
const items: Item[] = [ const menuConfig = useAppMenu();
{ const { pathname } = useLocation();
icon: BarChart3, const { isItemActive } = useMenu(pathname);
path: '/',
title: 'Dashboard'
},
{
icon: UserCircle,
path: '/public-profile/profiles/default',
title: 'Profile'
},
{
icon: Settings,
path: '/account/home/get-started',
title: 'Account'
},
{
icon: Users,
path: '/network/get-started',
title: 'Network',
active: true
},
{
icon: Shield,
path: '/account/billing/plans',
title: 'Plans'
},
{
icon: MessageSquare,
path: '/account/security/security-log',
title: 'Security Logs'
},
{
icon: Bell,
path: '/account/notifications',
title: 'Notifications'
},
{
icon: CheckSquare,
path: '/account/members/roles',
title: 'ACL'
},
{
icon: Code,
path: '/account/api-keys',
title: 'API Keys'
},
{
icon: HelpCircle,
path: 'https://docs.keenthemes.com/metronic-vite',
title: 'Docs'
}
];
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="flex grow flex-col items-center gap-2.5 py-3.5 lg:py-0"> <div className="flex grow flex-col items-center gap-2.5 py-3.5 lg:py-0">
{items.map((item, index) => ( {menuConfig.map((item, index) => {
<Tooltip key={index}> const path = firstMenuPath(item);
<TooltipTrigger asChild>
<Button if (!path) {
variant="ghost" return null;
shape="circle" }
mode="icon"
{...(item.active ? { 'data-state': 'open' } : {})} return (
className={cn( <Tooltip key={index}>
'data-[state=open]:bg-background data-[state=open]:border-input data-[state=open]:text-primary data-[state=open]:border', <TooltipTrigger asChild>
'hover:bg-background hover:border-input hover:text-primary hover:border' <Button
)} asChild
> variant="ghost"
<Link shape="circle"
to={item.path || ''} mode="icon"
{...(item.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})} {...(isItemActive(item) ? { 'data-state': 'open' } : {})}
className={cn(
'data-[state=open]:bg-background data-[state=open]:border-input data-[state=open]:text-primary data-[state=open]:border',
'hover:bg-background hover:border-input hover:text-primary hover:border'
)}
> >
<item.icon className="size-4.5!" /> <Link to={path} aria-label={item.title}>
</Link> {item.icon && <item.icon className="size-4.5!" />}
</Button> </Link>
</TooltipTrigger> </Button>
<TooltipContent side="right">{item.title}</TooltipContent> </TooltipTrigger>
</Tooltip> <TooltipContent side="right">{item.title}</TooltipContent>
))} </Tooltip>
);
})}
</div> </div>
</TooltipProvider> </TooltipProvider>
); );
@@ -1,87 +0,0 @@
import { ChevronRight } from 'lucide-react';
import { Fragment, ReactNode } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { MenuItem } from '#app/config/types';
import { useMenuConfig } from '#app/menu';
import { useMenu } from '#shared/hooks/useMenu';
import { Container } from '#shared/widgets/common/Container';
export interface ToolbarHeadingProps {
title?: string | ReactNode;
description?: string | ReactNode;
}
function Toolbar({ children }: { children?: ReactNode }) {
return (
<div className="mb-5 lg:mb-10">
<Container className="flex flex-wrap items-center justify-between gap-5">
{children}
</Container>
</div>
);
}
function ToolbarActions({ children }: { children?: ReactNode }) {
return <div className="flex items-center gap-2.5">{children}</div>;
}
function ToolbarBreadcrumbs() {
const menuConfig = useMenuConfig();
const { pathname } = useLocation();
const { getBreadcrumb, isActive } = useMenu(pathname);
const items: MenuItem[] = getBreadcrumb(menuConfig);
if (items.length === 0) {
return null;
}
return (
<div className="mb-2.5 flex items-center gap-1.5 text-xs font-medium lg:mb-0 lg:text-sm">
<div className="breadcrumb flex items-center gap-1">
{items.map((item, index) => {
const isLast = index === items.length - 1;
const active = item.path ? isActive(item.path) : false;
return (
<Fragment key={index}>
{item.path ? (
<Link
to={item.path}
className={cn(
'flex items-center gap-1',
active ? 'text-mono' : 'text-muted-foreground hover:text-primary'
)}
>
{item.title}
</Link>
) : (
<span className={cn(isLast ? 'text-mono' : 'text-muted-foreground')}>
{item.title}
</span>
)}
{!isLast && <ChevronRight className="muted-foreground size-3.5" />}
</Fragment>
);
})}
</div>
</div>
);
}
const ToolbarHeading = ({ title = '' }: ToolbarHeadingProps) => {
const { pathname } = useLocation();
const { getCurrentItem } = useMenu(pathname);
const menuConfig = useMenuConfig();
const item = getCurrentItem(menuConfig);
return (
<div className="flex flex-wrap items-center gap-1 lg:gap-5">
<h1 className="text-mono text-lg font-medium">{title || item?.title}</h1>
<ToolbarBreadcrumbs />
</div>
);
};
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
@@ -1,23 +1,18 @@
// DEMO 3 // DEMO 3
// https://keenthemes.com/metronic/tailwind/react/demo3 // https://keenthemes.com/metronic/tailwind/react/demo3
import { Download } from 'lucide-react'; import { Outlet } from 'react-router-dom';
import { useEffect } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom';
import { useBodyClass, useIsMobile } from '@crux-kit/hooks'; import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types'; import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider'; import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
import { Button } from '#shared/ui/Button';
import { Footer } from './components/Footer';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { Navbar } from './components/Navbar'; import { Navbar } from './components/Navbar';
import { Sidebar } from './components/Sidebar'; import { Sidebar } from './components/Sidebar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export function CompactSidebarLayout() { export function CompactSidebarLayout() {
const { pathname } = useLocation(); useLayoutSettings(LayoutNames.COMPACT_SIDEBAR);
const { setOption } = useSettings(); const isMobile = useIsMobile();
const isMobileMode = useIsMobile(); const { showToolbar, showFooter } = useLayoutChrome();
useBodyClass(` useBodyClass(`
[--header-height:58px] [--header-height:58px]
@@ -27,40 +22,27 @@ export function CompactSidebarLayout() {
bg-muted! bg-muted!
`); `);
useEffect(() => {
setOption('layout', LayoutNames.COMPACT_SIDEBAR);
setOption('container', 'fluid');
}, [setOption]);
return ( return (
<> <>
<div className="flex grow"> <div className="flex grow">
<Header /> <Header />
<div className="flex grow flex-col pt-(--header-height) lg:flex-row"> <div className="flex grow flex-col pt-(--header-height) lg:flex-row">
{!isMobileMode && <Sidebar />} {!isMobile && <Sidebar />}
<Navbar /> <Navbar />
<div className="bg-background border-border mx-5 mb-5 flex grow rounded-b-xl border-x border-b lg:ms-(--sidebar-width) lg:mt-(--navbar-height)"> <div className="bg-background border-border mx-5 mb-5 flex grow rounded-b-xl border-x border-b lg:ms-(--sidebar-width) lg:mt-(--navbar-height)">
<div className="kt-scrollable-y flex grow flex-col pt-7 lg:[scrollbar-width:auto] lg:**:data-[slot=container]:pe-2"> <div className="kt-scrollable-y flex grow flex-col pt-7 lg:[scrollbar-width:auto] lg:**:data-[slot=container]:pe-2">
<main className="grow" role="content"> <main className="grow" role="content">
{pathname !== '/' && ( {showToolbar && (
<Toolbar> <Toolbar>
<ToolbarHeading /> <ToolbarHeading />
<ToolbarActions>
<Button variant="outline" size="sm" asChild>
<Link to={'/account/home/get-started'}>
<Download />
Export
</Link>
</Button>
</ToolbarActions>
</Toolbar> </Toolbar>
)} )}
<Outlet /> <Outlet />
</main> </main>
<Footer /> {showFooter && <AppFooter />}
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,24 @@
# Two Column Sidebar layout
**Metronic 9 reference:** [Demo 4](https://keenthemes.com/metronic/tailwind/react/demo4)
Two sidebar columns: a narrow icon rail choosing the top-level section, and a wider column with that
section's children as an accordion. Below `lg` both columns move into a single sheet.
## Composition
| Piece | Source |
| ----------------------- | ---------------------------------------------------------------------- |
| Section chooser | `useAppMenu` + `firstMenuPath` from `../shared` |
| Section menu | `SidebarAccordionMenu` from `../shared`, fed by `useActiveMenuSection` |
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
| Footer | `AppFooter` from `../shared` |
| Account menu | `UserAvatarMenu` from `../shared` |
Layout-specific: `Sidebar`, `SidebarPrimary`, `SidebarSecondary`, `Header`.
## Notes
- When the active section has no children, the secondary column shows the whole menu rather than
going blank.
- Body variables: `--header-height: 60px`, `--sidebar-width: 290px`.
@@ -1,42 +0,0 @@
import { generalSettings } from '#app/config/general';
import { Container } from '#shared/widgets/common/Container';
export function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="footer">
<Container>
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
<span className="text-muted-foreground">{currentYear} &copy;</span>
<a
href="https://keenthemes.com"
target="_blank"
className="text-secondary-foreground hover:text-primary"
>
Keenthemes Inc.
</a>
</div>
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
Docs
</a>
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
Purchase
</a>
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
FAQ
</a>
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
Support
</a>
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
License
</a>
</nav>
</div>
</Container>
</footer>
);
}
@@ -1,10 +1,10 @@
import { Menu } from 'lucide-react'; import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button'; import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet'; import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { Container } from '#shared/widgets/common/Container'; import { Container } from '#shared/widgets/common/Container';
import { AppLogo } from '../../shared';
import { SidebarPrimary } from './SidebarPrimary'; import { SidebarPrimary } from './SidebarPrimary';
import { SidebarSecondary } from './SidebarSecondary'; import { SidebarSecondary } from './SidebarSecondary';
@@ -12,7 +12,6 @@ export function Header() {
const [isSheetOpen, setIsSheetOpen] = useState(false); const [isSheetOpen, setIsSheetOpen] = useState(false);
const { pathname } = useLocation(); const { pathname } = useLocation();
// Close sheet when route changes
useEffect(() => { useEffect(() => {
setIsSheetOpen(false); setIsSheetOpen(false);
}, [pathname]); }, [pathname]);
@@ -20,18 +19,7 @@ export function Header() {
return ( return (
<header className="fixed start-0 end-0 top-0 z-10 flex h-(--header-height) shrink-0 items-center bg-(--page-bg) lg:hidden dark:bg-(--page-bg-dark)"> <header className="fixed start-0 end-0 top-0 z-10 flex h-(--header-height) shrink-0 items-center bg-(--page-bg) lg:hidden dark:bg-(--page-bg-dark)">
<Container className="flex flex-wrap items-center justify-between gap-3"> <Container className="flex flex-wrap items-center justify-between gap-3">
<Link to="/"> <AppLogo className="size-7.5" />
<img
src={toAbsoluteUrl('/media/app/mini-logo-gray.svg')}
className="min-h-7.5 dark:hidden"
alt=""
/>
<img
src={toAbsoluteUrl('/media/app/mini-logo-gray-dark.svg')}
className="hidden min-h-7.5 dark:block"
alt=""
/>
</Link>
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}> <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
@@ -1,161 +0,0 @@
import { Badge, ChevronDown, FileText, Settings, SquareCode, UserCircle } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel } from '#shared/ui/AccordionMenu';
import { Button } from '#shared/ui/Button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
interface DropdownItem {
title: string;
path: string;
icon: React.ComponentType<{ className?: string }>;
active?: boolean;
}
interface MenuItem {
title: string;
path?: string;
active?: boolean;
}
interface MenuLabel {
label: string;
}
type MenuNode = MenuItem | MenuLabel;
export function SidebarMenuDashboard() {
const { pathname } = useLocation();
const dropdownItems: DropdownItem[] = [
{
title: 'Client API',
path: '/account/home/user-profile',
icon: SquareCode,
active: true
},
{
title: 'Profile',
path: '/public-profile/profiles/company',
icon: UserCircle
},
{
title: 'My Account',
path: '/account/integrations',
icon: Settings
},
{
title: 'Projects',
path: '/public-profile/projects/3-columns',
icon: FileText
},
{
title: 'Personal info',
path: '/public-profile/profiles/creator',
icon: Badge
}
];
const currentDropdownItem = dropdownItems[0];
const menuItems = useMemo<MenuNode[]>(
() => [
{ label: 'Configuration' },
{ title: 'API Setup', path: '/account/api-keys' },
{ title: 'Team Settings', path: '/account/home/settings-sidebar' },
{ title: 'Authentication', path: '/authentication/classic/sign-in' },
{ title: 'Endpoints Configs', path: '/account/appearance' },
{ title: 'Rate Limiting', path: '/public-profile/network' },
{ label: 'Security' },
{ title: 'Data Encryption', path: '/account/billing/enterprise' },
{ title: 'Text', path: '/account/security/overview' },
{ title: 'Access Control', path: '/account/security/privacy-settings' },
{ label: 'Analytics' },
{
title: 'Incident Response',
path: '/account/security/current-sessions'
},
{ title: 'Fetching Data', path: '/account/members/team-info' },
{ title: 'Custom Reports', path: '/account/home/user-profile' },
{
title: 'Real Time Analytics',
path: '/account/home/settings-enterprise'
},
{ title: 'Exporting Data', path: '/account/members/import-members' },
{ title: 'Dashboard Integration', path: '/account/members/team-info' }
],
[]
);
const classNames: AccordionMenuClassNames = {
root: 'space-y-1',
label: 'uppercase text-xs font-medium text-muted-foreground/80 pt-6 mb-2 pb-0',
item: 'h-8 hover:bg-background border-accent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium'
};
const matchPath = useCallback(
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
[pathname]
);
const buildDropdown = () => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" mode="input" className="w-full justify-between">
<span className="flex items-center gap-2">
<currentDropdownItem.icon />
{currentDropdownItem.title}
</span>
<ChevronDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-(--radix-dropdown-menu-trigger-width)">
{dropdownItems.map((item, index) => (
<DropdownMenuItem key={index} asChild>
<Link to={item.path}>
<item.icon />
<span>{item.title}</span>
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
const buildMenu = () => {
return (
<AccordionMenu
selectedValue={'/account/home/settings-sidebar'}
matchPath={matchPath}
type="single"
collapsible
classNames={classNames}
>
<AccordionMenuGroup>
{menuItems.map((item, index) =>
'label' in item ? (
<AccordionMenuLabel key={index}>{item.label}</AccordionMenuLabel>
) : (
<AccordionMenuItem
key={index}
value={item.path || `item-${index}`}
className="text-sm"
>
<Link to={item.path || '#'}>{item.title}</Link>
</AccordionMenuItem>
)
)}
</AccordionMenuGroup>
</AccordionMenu>
);
};
return (
<div className="w-full space-y-1">
{buildDropdown()}
{buildMenu()}
</div>
);
}
@@ -1,182 +0,0 @@
'use client';
import { JSX, useCallback } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { MenuConfig, MenuItem } from '#app/config/types';
import { useMenuConfig } from '#app/menu';
import { useMenu } from '#shared/hooks/useMenu';
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
import { Badge } from '#shared/ui/Badge';
export function SidebarMenuDefault() {
const { pathname } = useLocation();
// Memoize matchPath to prevent unnecessary re-renders
const matchPath = useCallback(
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
[pathname]
);
// Global classNames for consistent styling
const classNames: AccordionMenuClassNames = {
root: 'lg:ps-1 space-y-1',
group: 'gap-px',
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
separator: '',
item: 'h-8 hover:bg-background border-accent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium',
sub: '',
subTrigger:
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-transparent data-[selected=true]:font-medium',
subContent: 'ps-3 py-0',
indicator: ''
};
const buildMenu = (items: MenuConfig): JSX.Element[] => {
return items.map((item: MenuItem, index: number) => {
if (item.heading) {
return buildMenuHeading(item, index);
} else if (item.disabled) {
return buildMenuItemRootDisabled(item, index);
} else {
return buildMenuItemRoot(item, index);
}
});
};
const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
if (item.children) {
return (
<AccordionMenuSub key={index} value={item.path || `root-${index}`}>
<AccordionMenuSubTrigger className="text-sm">
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<span data-slot="accordion-menu-title">{item.title}</span>
</AccordionMenuSubTrigger>
<AccordionMenuSubContent
type="single"
collapsible
parentValue={item.path || `root-${index}`}
>
<AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
</AccordionMenuSubContent>
</AccordionMenuSub>
);
} else {
return (
<AccordionMenuItem key={index} value={item.path || ''} className="text-sm">
<Link to={item.path || '#'} className="flex grow items-center justify-between gap-2">
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<span data-slot="accordion-menu-title">{item.title}</span>
</Link>
</AccordionMenuItem>
);
}
};
const buildMenuItemRootDisabled = (item: MenuItem, index: number): JSX.Element => {
return (
<AccordionMenuItem key={index} value={`disabled-${index}`} className="text-sm font-medium">
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<span data-slot="accordion-menu-title">{item.title}</span>
{item.disabled && (
<Badge variant="secondary" size="sm" className="ms-auto -me-2.5">
Soon
</Badge>
)}
</AccordionMenuItem>
);
};
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
return items.map((item: MenuItem, index: number) => {
if (item.disabled) {
return buildMenuItemChildDisabled(item, index, level);
} else {
return buildMenuItemChild(item, index, level);
}
});
};
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
if (item.children) {
return (
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
<AccordionMenuSubTrigger className="text-[13px]">
{item.collapse ? (
<span className="text-muted-foreground">
<span className="hidden [[data-state=open]>span>&]:inline">
{item.collapseTitle}
</span>
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
</span>
) : (
item.title
)}
</AccordionMenuSubTrigger>
<AccordionMenuSubContent
type="single"
collapsible
parentValue={item.path || `child-${level}-${index}`}
className={cn(
'ps-4',
!item.collapse && 'relative',
!item.collapse && (level > 0 ? '' : '')
)}
>
<AccordionMenuGroup>
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
</AccordionMenuGroup>
</AccordionMenuSubContent>
</AccordionMenuSub>
);
} else {
return (
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
<Link to={item.path || '#'}>{item.title}</Link>
</AccordionMenuItem>
);
}
};
const buildMenuItemChildDisabled = (
item: MenuItem,
index: number,
level: number = 0
): JSX.Element => {
return (
<AccordionMenuItem
key={index}
value={`disabled-child-${level}-${index}`}
className="text-[13px]"
>
<span data-slot="accordion-menu-title">{item.title}</span>
{item.disabled && (
<Badge variant="secondary" size="sm" className="ms-auto -me-2.5">
Soon
</Badge>
)}
</AccordionMenuItem>
);
};
const buildMenuHeading = (item: MenuItem, index: number): JSX.Element => {
return <AccordionMenuLabel key={index}>{item.heading}</AccordionMenuLabel>;
};
// Memoize the menu to prevent unnecessary re-renders
const { getChildren } = useMenu(pathname);
const menuConfig = useMenuConfig();
const menuItems = getChildren(menuConfig, 0);
return (
<AccordionMenu
selectedValue={pathname}
matchPath={matchPath}
type="single"
collapsible
classNames={classNames}
>
{buildMenu(menuItems || [])}
</AccordionMenu>
);
}
@@ -1,184 +1,57 @@
import { BarChart3, Bell, CheckSquare, Code, MessageSquare, Settings, Shield, ShoppingCart, UserCircle, Users } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useViewport } from '@crux-kit/hooks'; import { cn } from '@crux-kit/utils';
import { cn, getHeight } from '@crux-kit/utils'; import { useMenu } from '#shared/hooks/useMenu';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button'; import { Button } from '#shared/ui/Button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip';
// import { AppsDropdownMenu } from '#shared/partials/topbar/apps-dropdown-menu'; import { AppLogo, firstMenuPath, useAppMenu, UserAvatarMenu } from '../../shared';
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
interface MenuItem {
icon: React.ComponentType<{ className?: string }>;
tooltip: string;
path: string;
rootPath?: string;
}
const menuItems: MenuItem[] = [
{ icon: BarChart3, tooltip: 'Dashboard', path: '/', rootPath: '/' },
{
icon: UserCircle,
tooltip: 'Profile',
path: '/public-profile/profiles/default',
rootPath: '/public-profile/'
},
{
icon: Settings,
tooltip: 'Account',
path: '/account/home/get-started',
rootPath: '/account/'
},
{
icon: Users,
tooltip: 'Network',
path: '/network/get-started',
rootPath: '/network/'
},
{
icon: ShoppingCart,
tooltip: 'Store - Client',
path: '/store-client/home',
rootPath: ''
},
{
icon: Shield,
tooltip: 'Authentication',
path: '/authentication/get-started',
rootPath: '/authentication/'
},
{
icon: MessageSquare,
tooltip: 'Security Logs',
path: '/account/security/security-log',
rootPath: '/account/'
},
{
icon: Bell,
tooltip: 'Notifications',
path: '/account/notifications',
rootPath: ''
},
{
icon: CheckSquare,
tooltip: 'ACL',
path: '/account/members/roles',
rootPath: ''
},
{ icon: Code, tooltip: 'API Keys', path: '/account/api-keys', rootPath: '' }
];
export function SidebarPrimary() { export function SidebarPrimary() {
const headerRef = useRef<HTMLDivElement>(null); const menuConfig = useAppMenu();
const footerRef = useRef<HTMLDivElement>(null);
const [scrollableHeight, setScrollableHeight] = useState<number>(0);
const [viewportHeight] = useViewport();
const scrollableOffset = 80;
const { pathname } = useLocation(); const { pathname } = useLocation();
const { isItemActive } = useMenu(pathname);
useEffect(() => {
if (headerRef.current && footerRef.current) {
const headerHeight = getHeight(headerRef.current);
const footerHeight = getHeight(footerRef.current);
const availableHeight = viewportHeight - headerHeight - footerHeight - scrollableOffset;
setScrollableHeight(availableHeight);
} else {
setScrollableHeight(viewportHeight);
}
}, [viewportHeight]);
const [selectedMenuItem, setSelectedMenuItem] = useState(menuItems[0]);
useEffect(() => {
menuItems.forEach((item) => {
if (item.rootPath === pathname || (item.rootPath && pathname.includes(item.rootPath))) {
setSelectedMenuItem(item);
}
});
}, [pathname]);
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="border-input flex w-17.5 shrink-0 flex-col items-stretch gap-5 border-e py-5"> <div className="border-input flex w-17.5 shrink-0 flex-col items-stretch gap-5 border-e py-5">
<div ref={headerRef} className="hidden shrink-0 items-center justify-center lg:flex"> <div className="hidden shrink-0 items-center justify-center lg:flex">
<Link to="/"> <AppLogo className="size-7.5" />
<img
src={toAbsoluteUrl('/media/app/mini-logo-gray.svg')}
className="min-h-7.5 dark:hidden"
alt=""
/>
<img
src={toAbsoluteUrl('/media/app/mini-logo-gray-dark.svg')}
className="hidden min-h-7.5 dark:block"
alt=""
/>
</Link>
</div> </div>
<div className="flex shrink-0 grow"> <div className="kt-scrollable-y-hover flex shrink-0 grow flex-col gap-2.5 ps-4">
<div {menuConfig.map((item, index) => {
className="kt-scrollable-y-hover flex shrink-0 grow flex-col gap-2.5 ps-4" const path = firstMenuPath(item);
style={{ height: `${scrollableHeight}px` }}
> if (!path) {
{menuItems.map((item, index) => ( return null;
}
return (
<Tooltip key={index}> <Tooltip key={index}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
asChild asChild
variant="ghost" variant="ghost"
mode="icon" mode="icon"
{...(item === selectedMenuItem ? { 'data-state': 'open' } : {})} {...(isItemActive(item) ? { 'data-state': 'open' } : {})}
className={cn( className={cn(
'size-9 shrink-0 rounded-md', 'size-9 shrink-0 rounded-md',
'data-[state=open]:bg-background data-[state=open]:border-input data-[state=open]:text-primary data-[state=open]:border', 'data-[state=open]:bg-background data-[state=open]:border-input data-[state=open]:text-primary data-[state=open]:border',
'hover:bg-background hover:border-input hover:text-primary hover:border' 'hover:bg-background hover:border-input hover:text-primary hover:border'
)} )}
> >
<Link to={item.path}> <Link to={path} aria-label={item.title}>
<item.icon className="size-4.5!" /> {item.icon && <item.icon className="size-4.5!" />}
</Link> </Link>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">{item.tooltip}</TooltipContent> <TooltipContent side="right">{item.title}</TooltipContent>
</Tooltip> </Tooltip>
))} );
</div> })}
</div> </div>
<div ref={footerRef} className="flex shrink-0 flex-col items-center gap-4"> <div className="flex shrink-0 flex-col items-center gap-4">
{/* <ChatSheet <UserAvatarMenu />
trigger={
<Button
variant="ghost"
mode="icon"
className="size-9 hover:bg-background hover:[&_svg]:text-primary"
>
<MessageCircleMore className="size-4.5!" />
</Button>
}
/>
<AppsDropdownMenu
trigger={
<Button
variant="ghost"
mode="icon"
className="size-9 hover:bg-background hover:[&_svg]:text-primary"
>
<LayoutGrid className="size-4.5!" />
</Button>
}
/> */}
<UserDropdownMenu
trigger={
<img
className="border-border size-9 shrink-0 cursor-pointer rounded-full border"
src={toAbsoluteUrl('/media/avatars/300-2.png')}
alt="User Avatar"
/>
}
/>
</div> </div>
</div> </div>
</TooltipProvider> </TooltipProvider>
@@ -1,13 +1,30 @@
import { useLocation } from 'react-router'; import { SidebarAccordionMenu, useActiveMenuSection } from '../../shared';
import { SidebarMenuDashboard } from './SidebarMenuDashboard';
import { SidebarMenuDefault } from './SidebarMenuDefault';
const CLASS_NAMES = {
root: 'lg:ps-1 space-y-1',
group: 'gap-px',
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
separator: '',
item: 'h-8 hover:bg-background border-accent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium',
sub: '',
subTrigger:
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-transparent data-[selected=true]:font-medium',
subContent: 'ps-3 py-0',
indicator: ''
};
/**
* The children of the section chosen in the primary column. A section without children (a
* single-page entry such as the dashboard) falls back to the whole menu, so this column is never
* blank.
*/
export function SidebarSecondary() { export function SidebarSecondary() {
const { pathname } = useLocation(); const section = useActiveMenuSection();
const items = section?.children;
return ( return (
<div className="kt-scrollable-y-hover my-5 max-h-[calc(100vh-2rem)] shrink-0 grow ps-3.5 pe-1"> <div className="kt-scrollable-y-hover my-5 max-h-[calc(100vh-2rem)] shrink-0 grow ps-3.5 pe-1">
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />} <SidebarAccordionMenu classNames={CLASS_NAMES} items={items} />
</div> </div>
); );
} }
@@ -1,83 +0,0 @@
import { Fragment, ReactNode } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { MenuItem } from '#app/config/types';
import { useMenuConfig } from '#app/menu';
import { useMenu } from '#shared/hooks/useMenu';
import { Container } from '#shared/widgets/common/Container';
export interface ToolbarHeadingProps {
title?: string | ReactNode;
description?: string | ReactNode;
}
function Toolbar({ children }: { children?: ReactNode }) {
return (
<div className="mb-5">
<Container className="flex flex-wrap items-center justify-between gap-5">
{children}
</Container>
</div>
);
}
function ToolbarActions({ children }: { children?: ReactNode }) {
return <div className="flex items-center gap-2.5">{children}</div>;
}
function ToolbarBreadcrumbs() {
const { pathname } = useLocation();
const menuConfig = useMenuConfig();
const { getBreadcrumb, isActive } = useMenu(pathname);
const items: MenuItem[] = getBreadcrumb(menuConfig);
if (items.length === 0) {
return null;
}
return (
<div className="flex items-center gap-1 text-sm">
{items.map((item, index) => {
const isLast = index === items.length - 1;
const active = item.path ? isActive(item.path) : false;
return (
<Fragment key={index}>
{item.path ? (
<Link
to={item.path}
className={cn(
'flex items-center gap-1',
active ? 'text-mono' : 'text-secondary-foreground hover:text-primary'
)}
>
{item.title}
</Link>
) : (
<span className={cn(isLast ? 'text-mono' : 'text-secondary-foreground')}>
{item.title}
</span>
)}
{!isLast && <span className="text-secondary-foreground">/</span>}
</Fragment>
);
})}
</div>
);
}
const ToolbarHeading = ({ title = '' }: ToolbarHeadingProps) => {
const { pathname } = useLocation();
const { getCurrentItem } = useMenu(pathname);
const menuConfig = useMenuConfig();
const item = getCurrentItem(menuConfig);
return (
<div className="flex flex-wrap items-center gap-1 lg:gap-5">
<h1 className="text-mono text-lg font-medium">{title || item?.title}</h1>
<ToolbarBreadcrumbs />
</div>
);
};
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
@@ -1,25 +1,18 @@
// DEMO 4 // DEMO 4
// https://keenthemes.com/metronic/tailwind/react/demo4 // https://keenthemes.com/metronic/tailwind/react/demo4
import { Download } from 'lucide-react'; import { Outlet } from 'react-router-dom';
import { useEffect } from 'react';
import { Link, Outlet } from 'react-router-dom';
import { useBodyClass, useIsMobile } from '@crux-kit/hooks'; import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types'; import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider'; import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
import { Button } from '#shared/ui/Button';
// import { SearchDialog } from '#shared/partials/dialogs/search/search-dialog';
// import { NotificationsSheet } from '#shared/partials/topbar/notifications-sheet';
import { Footer } from './components/Footer';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar'; import { Sidebar } from './components/Sidebar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export function TwoColumnSidebarLayout() { export function TwoColumnSidebarLayout() {
const { setOption } = useSettings(); useLayoutSettings(LayoutNames.TWO_COLUMN_SIDEBAR);
const isMobileMode = useIsMobile(); const isMobile = useIsMobile();
const { showToolbar, showFooter } = useLayoutChrome();
// Using the custom hook to set multiple CSS variables and class properties
useBodyClass(` useBodyClass(`
[--header-height:60px] [--header-height:60px]
[--sidebar-width:290px] [--sidebar-width:290px]
@@ -27,64 +20,27 @@ export function TwoColumnSidebarLayout() {
bg-muted! bg-muted!
`); `);
useEffect(() => {
setOption('layout', LayoutNames.TWO_COLUMN_SIDEBAR);
}, [setOption]);
return ( return (
<> <>
<div className="flex grow"> <div className="flex grow">
{isMobileMode && <Header />} {isMobile && <Header />}
<div className="flex grow flex-col pt-(--header-height) lg:flex-row lg:pt-0"> <div className="flex grow flex-col pt-(--header-height) lg:flex-row lg:pt-0">
{!isMobileMode && <Sidebar />} {!isMobile && <Sidebar />}
<div className="bg-background border-input m-5 mt-0 flex grow rounded-xl border lg:ms-(--sidebar-width) lg:mt-5"> <div className="bg-background border-input m-5 mt-0 flex grow rounded-xl border lg:ms-(--sidebar-width) lg:mt-5">
<div className="kt-scrollable-y-auto flex grow flex-col pt-5 lg:[--kt-scrollbar-width:auto]"> <div className="kt-scrollable-y-auto flex grow flex-col pt-5 lg:[--kt-scrollbar-width:auto]">
<main className="grow" role="content"> <main className="grow" role="content">
<Toolbar> {showToolbar && (
<ToolbarHeading /> <Toolbar>
<ToolbarHeading />
<ToolbarActions> </Toolbar>
{/* <SearchDialog )}
trigger={
<Button
variant="ghost"
mode="icon"
className="hover:bg-primary/10 hover:[&_svg]:text-primary"
>
<Search className="size-4.5!" />
</Button>
}
/>
<NotificationsSheet
trigger={
<Button
variant="ghost"
mode="icon"
className="hover:bg-primary/10 hover:[&_svg]:text-primary"
>
<MessageSquareDot className="size-4.5!" />
</Button>
}
/> */}
<Button
variant="outline"
className="hover:bg-background hover:[&_svg]:text-primary hover:text-primary ms-2.5"
asChild
>
<Link to={'/account/home/get-started'}>
<Download />
Export
</Link>
</Button>
</ToolbarActions>
</Toolbar>
<Outlet /> <Outlet />
</main> </main>
<Footer /> {showFooter && <AppFooter />}
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,25 @@
# Dual Rows Header layout
**Metronic 9 reference:** [Demo 5](https://keenthemes.com/metronic/tailwind/react/demo5)
A two-row header — logo and account menu on top, the top-level sections on a menubar below — with
the selected section's children in a slim sidebar beside the content.
## Composition
| Piece | Source |
| ----------------------- | ---------------------------------------------------------------------- |
| Section menubar | `NavMenubar` from `../shared` |
| Section sidebar | `SidebarAccordionMenu` from `../shared`, fed by `useActiveMenuSection` |
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
| Footer | `AppFooter` from `../shared` |
| Account menu | `UserAvatarMenu` from `../shared` |
Layout-specific: `Header`, `HeaderLogo`, `Navbar`, `Sidebar`.
## Notes
- A section with no children renders no sidebar, so the content spans the full width.
- This layout sets `container` to `fluid`.
- Body variables: `--header-height: 54px`, `--sidebar-width: 200px`.
@@ -1,42 +0,0 @@
import { generalSettings } from '#app/config/general';
import { Container } from '#shared/widgets/common/Container';
export function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="footer">
<Container>
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
<span className="text-muted-foreground">{currentYear} &copy;</span>
<a
href="https://keenthemes.com"
target="_blank"
className="text-secondary-foreground hover:text-primary"
>
Keenthemes Inc.
</a>
</div>
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
Docs
</a>
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
Purchase
</a>
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
FAQ
</a>
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
Support
</a>
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
License
</a>
</nav>
</div>
</Container>
</footer>
);
}
@@ -4,42 +4,43 @@ import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types'; import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider'; import { useSettings } from '#app/providers/SettingsProvider';
import { Container } from '#shared/widgets/common/Container'; import { Container } from '#shared/widgets/common/Container';
import { UserAvatarMenu } from '../../shared';
import { HeaderLogo } from './HeaderLogo'; import { HeaderLogo } from './HeaderLogo';
import { HeaderTopbar } from './HeaderTopbar';
const Header = () => { export function Header() {
const { settings } = useSettings(); const { settings } = useSettings();
const scrollPosition = useScrollPosition(); const scrollPosition = useScrollPosition();
const [headerStickyOn, setHeaderStickyOn] = useState(false); const [headerStickyOn, setHeaderStickyOn] = useState(false);
useEffect(() => { const { headerSticky, headerStickyOffset } = settings.layouts[LayoutNames.DUAL_ROW_HEADER];
const isSticky =
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
setHeaderStickyOn(isSticky);
}, [scrollPosition, settings]);
useEffect(() => { useEffect(() => {
if (headerStickyOn === true) { setHeaderStickyOn(scrollPosition > headerStickyOffset);
}, [scrollPosition, headerStickyOffset]);
useEffect(() => {
if (headerStickyOn) {
document.body.setAttribute('data-sticky-header', 'on'); document.body.setAttribute('data-sticky-header', 'on');
} else { } else {
document.body.removeAttribute('data-sticky-header'); document.body.removeAttribute('data-sticky-header');
} }
return () => document.body.removeAttribute('data-sticky-header');
}, [headerStickyOn]); }, [headerStickyOn]);
return ( return (
<header <header
className={cn( className={cn(
'flex h-(--header-height) shrink-0 items-center bg-(--header-bg) transition-[height] dark:bg-(--header-bg-dark)', 'flex h-(--header-height) shrink-0 items-center transition-[height]',
headerStickyOn && headerSticky &&
'fixed start-0 end-0 top-0 z-10 bg-white/70 shadow-xs backdrop-blur-md transition-[height]' headerStickyOn &&
'bg-background/70 fixed start-0 end-0 top-0 z-10 shadow-xs backdrop-blur-md transition-[height]'
)} )}
> >
<Container width="fluid" className="flex flex-wrap items-center justify-between lg:gap-4"> <Container className="flex items-center justify-between gap-4">
<HeaderLogo /> <HeaderLogo />
<HeaderTopbar /> <UserAvatarMenu />
</Container> </Container>
</header> </header>
); );
}; }
export { Header };
@@ -1,108 +1,25 @@
import { ChevronDown, Menu, Settings, Shield, UserCircle, Users, type LucideIcon } from 'lucide-react'; import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useIsMobile } from '@crux-kit/hooks'; import { useIsMobile } from '@crux-kit/hooks';
import { cn } from '@crux-kit/utils';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button'; import { Button } from '#shared/ui/Button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet'; import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { SidebarMenuDashboard } from './SidebarMenuDashboard'; import { AppLogo, AppWordmark, SidebarAccordionMenu } from '../../shared';
import { SidebarMenuDefault } from './SidebarMenuDefault';
interface HeaderLogoTeam {
title: string;
icon?: LucideIcon;
urlPartial: string;
path: string;
}
type HeaderLogoTeams = Array<HeaderLogoTeam>;
interface HeaderLogoItem {
title: string;
icon?: LucideIcon;
}
type HeaderLogoItems = Array<HeaderLogoItem>;
interface HeaderLogoStaging {
title: string;
icon?: LucideIcon;
}
type HeaderLogoStagings = Array<HeaderLogoStaging>;
export function HeaderLogo() { export function HeaderLogo() {
const { pathname } = useLocation(); const { pathname } = useLocation();
const [isSheetOpen, setIsSheetOpen] = useState(false); const [isSheetOpen, setIsSheetOpen] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const teams: HeaderLogoTeams = [
{
title: 'MetronicTeam',
icon: UserCircle,
urlPartial: '/public-profile/',
path: '/public-profile/profiles/default'
},
{
title: 'KeenTeam',
icon: Settings,
urlPartial: '/account/',
path: '/'
}
];
const items: HeaderLogoItems = [
{
title: 'Campaign',
icon: UserCircle
},
{
title: 'Fall Winter 2024 ',
icon: Settings
},
{
title: 'Barberry Autmn 24',
icon: Users
},
{
title: 'PF24 Advertising',
icon: Shield
}
];
const stagings: HeaderLogoStagings = [
{
title: 'Staging',
icon: UserCircle
},
{
title: 'Account',
icon: Settings
}
];
const [selectedTeam, setSelectedTeam] = useState(teams[0]);
const [selectedItem, setSelectedItem] = useState(items[0]);
const [selectedStaging, setSelectedStaging] = useState(stagings[0]);
// Close sheet when route changes
useEffect(() => { useEffect(() => {
setIsSheetOpen(false); setIsSheetOpen(false);
}, [pathname]); }, [pathname]);
return ( return (
<div className="flex items-center gap-1.5 lg:gap-5"> <div className="flex items-center gap-1.5 lg:gap-5">
<Link to="/"> <AppLogo className="size-8.5" />
<img
src={toAbsoluteUrl('/media/app/mini-logo-circle.svg')} <AppWordmark className="text-accent-foreground hidden text-base md:block" />
className="min-h-8.5 dark:hidden"
alt="logo"
/>
<img
src={toAbsoluteUrl('/media/app/mini-logo-circle-dark.svg')}
className="hidden min-h-8.5 dark:inline-block"
alt="logo"
/>
</Link>
{isMobile && ( {isMobile && (
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}> <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
@@ -113,89 +30,12 @@ export function HeaderLogo() {
</SheetTrigger> </SheetTrigger>
<SheetContent className="w-62.5 gap-0 p-0" side="left" close={false}> <SheetContent className="w-62.5 gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" /> <SheetHeader className="space-y-0 p-0" />
<SheetBody className="overflow-y-auto p-3"> <SheetBody className="overflow-y-auto py-5">
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />} <SidebarAccordionMenu />
</SheetBody> </SheetBody>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
)} )}
{!isMobile && (
<div className="items-stretch gap-3 lg:flex">
{/* Teams Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className="text-secondary-foreground flex cursor-pointer items-center gap-2 text-sm font-medium">
{selectedTeam.title}
<ChevronDown className="text-muted-foreground size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={15} side="bottom" align="start">
{teams.map((team, index) => (
<DropdownMenuItem
key={index}
asChild
className={cn(team === selectedTeam && 'bg-accent')}
onSelect={() => setSelectedTeam(team)}
>
<Link to={team.path}>
{team.icon && <team.icon />}
{team.title}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<span className="text-muted-foreground px-2.5 text-sm font-medium md:inline">/</span>
{/* Items Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className="text-secondary-foreground flex cursor-pointer items-center gap-2 text-sm font-medium">
{selectedItem.title}
<ChevronDown className="text-muted-foreground size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={15} side="bottom" align="start">
{items.map((item, index) => (
<DropdownMenuItem
key={index}
asChild
className={cn(item === selectedItem && 'bg-accent')}
onSelect={() => setSelectedItem(item)}
>
<Link to="/">
{item.icon && <item.icon />}
{item.title}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<span className="text-muted-foreground px-2.5 text-sm font-medium">/</span>
{/* Staging Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className="text-secondary-foreground flex cursor-pointer items-center gap-2 text-sm font-medium">
{selectedStaging.title}
<ChevronDown className="text-muted-foreground size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={15} side="bottom" align="start">
{stagings.map((staging, index) => (
<DropdownMenuItem
key={index}
asChild
className={cn(staging === selectedStaging && 'bg-accent')}
onSelect={() => setSelectedStaging(staging)}
>
<Link to="/">
{staging.icon && <staging.icon />}
{staging.title}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div> </div>
); );
} }
@@ -1,83 +0,0 @@
import { Users } from 'lucide-react';
import { Link } from 'react-router-dom';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button';
// import { SearchDialog } from '#shared/partials/dialogs/search/search-dialog';
// import { AppsDropdownMenu } from '#shared/partials/topbar/apps-dropdown-menu';
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
// import { NotificationsSheet } from '#shared/partials/topbar/notifications-sheet';
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
export function HeaderTopbar() {
return (
<div className="flex items-center gap-2 lg:gap-3.5">
<Button variant="outline" asChild>
<Link to="/account/members/team-members">
<Users />
Add <span className="hidden md:inline">Teammate</span>
</Link>
</Button>
<div className="flex items-center gap-1">
{/* <SearchDialog
trigger={
<Button
variant="ghost"
mode="icon"
shape="circle"
className="hover:bg-transparent hover:[&_svg]:text-primary"
>
<Search className="size-4.5!" />
</Button>
}
/> */}
{/* <ChatSheet
trigger={
<Button
variant="ghost"
mode="icon"
shape="circle"
className="hover:bg-transparent hover:[&_svg]:text-primary"
>
<MessageCircleMore className="size-4.5!" />
</Button>
}
/> */}
{/* <AppsDropdownMenu
trigger={
<Button
variant="ghost"
mode="icon"
shape="circle"
className="hover:bg-transparent hover:[&_svg]:text-primary"
>
<LayoutGrid className="size-4.5!" />
</Button>
}
/> */}
{/* <NotificationsSheet
trigger={
<Button
variant="ghost"
mode="icon"
shape="circle"
className="hover:bg-transparent hover:[&_svg]:text-primary"
>
<MessageSquareDot className="size-4.5!" />
</Button>
}
/> */}
</div>
<UserDropdownMenu
trigger={
<img
className="border-mono/25 size-9 shrink-0 cursor-pointer rounded-full border-2"
src={toAbsoluteUrl('/media/avatars/300-2.png')}
alt="User Avatar"
/>
}
/>
</div>
);
}
@@ -1,11 +1,19 @@
import { useIsMobile } from '@crux-kit/hooks';
import { Container } from '#shared/widgets/common/Container'; import { Container } from '#shared/widgets/common/Container';
import { NavbarMenu } from './NavbarMenu'; import { NavMenubar } from '../../shared';
/** The top-level sections, on the header's second row. */
const Navbar = () => { const Navbar = () => {
const isMobile = useIsMobile();
if (isMobile) {
return null;
}
return ( return (
<div className="bg-bg-background border-border mb-5 border-y lg:mb-8"> <div className="bg-bg-background border-border mb-5 border-y lg:mb-8">
<Container className="flex flex-wrap items-center justify-between gap-2"> <Container className="flex flex-wrap items-center justify-between gap-2">
<NavbarMenu /> <NavMenubar />
</Container> </Container>
</div> </div>
); );
@@ -1,74 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
export function NavbarMenu() {
const { pathname } = useLocation();
const items = useMemo(
() => [
{
title: 'Dashboards',
path: '/',
partial: '/'
},
{
title: 'Public Profiles',
path: '/public-profile/profiles/default',
partial: '/public-profile'
},
{
title: 'Account Settings',
path: '/account/home/get-started',
partial: '/account'
},
{
title: 'Network',
path: '/network/get-started',
partial: '/network'
},
{
title: 'Store - Client',
path: '/store-client/home',
partial: '/store-client'
},
{
title: 'Authentication',
path: '/authentication/get-started',
partial: '/authentication'
}
],
[] // Empty dependency array since the data is static
);
const [selectedItem, setSelectedItem] = useState(items[0]);
useEffect(() => {
items.forEach((item) => {
if (item.partial !== '' && pathname.startsWith(item.partial)) {
setSelectedItem(item);
}
});
}, [items, pathname]);
return (
<div className="grid">
<div className="kt-scrollable-x-auto">
<div className="flex h-12 items-stretch gap-5 lg:gap-7.5">
{items.map((item, index) => (
<Link
key={index}
to={item.path}
className={cn(
'text-secondary-foreground flex items-center text-sm font-medium text-nowrap',
item.path === selectedItem.path && 'border-mono text-mono border-b'
)}
>
{item.title}
</Link>
))}
</div>
</div>
</div>
);
}
@@ -1,13 +1,30 @@
import { useLocation } from 'react-router-dom'; import { SidebarAccordionMenu, useActiveMenuSection } from '../../shared';
import { SidebarMenuDashboard } from './SidebarMenuDashboard';
import { SidebarMenuDefault } from './SidebarMenuDefault';
const CLASS_NAMES = {
root: 'space-y-1 py-5',
group: 'gap-px',
label: 'uppercase text-xs font-medium text-muted-foreground/80 pt-6 mb-2 pb-0',
separator: '',
item: 'h-8 hover:bg-background border-accent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium',
sub: '',
subTrigger:
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-transparent data-[selected=true]:font-medium',
subContent: 'ps-3 py-0',
indicator: ''
};
/** The children of the section selected on the navbar. */
export function Sidebar() { export function Sidebar() {
const { pathname } = useLocation(); const section = useActiveMenuSection();
const items = section?.children;
if (!items || items.length === 0) {
return null;
}
return ( return (
<div className="bg-background flex w-(--sidebar-width) shrink-0 items-stretch px-2"> <div className="bg-background flex w-(--sidebar-width) shrink-0 items-stretch px-2">
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />} <SidebarAccordionMenu classNames={CLASS_NAMES} items={items} />
</div> </div>
); );
} }
@@ -1,171 +0,0 @@
import { Badge, ChevronDown, FileText, LucideIcon, Settings, SquareCode, UserCircle } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel } from '#shared/ui/AccordionMenu';
import { Button } from '#shared/ui/Button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
interface DropdownItem {
title: string;
path: string;
icon: LucideIcon;
active?: boolean;
}
interface MenuItem {
title: string;
path?: string;
active?: boolean;
}
interface MenuLabel {
label: string;
}
type MenuNode = MenuItem | MenuLabel;
export function SidebarMenuDashboard() {
const { pathname } = useLocation();
const dropdownItems = useMemo<DropdownItem[]>(
() => [
{
title: 'Client API',
path: '/account/home/user-profile',
icon: SquareCode,
active: true
},
{
title: 'Profile',
path: '/public-profile/profiles/company',
icon: UserCircle
},
{
title: 'My Account',
path: '/account/integrations',
icon: Settings
},
{
title: 'Projects',
path: '/public-profile/projects/3-columns',
icon: FileText
},
{
title: 'Personal info',
path: '/public-profile/profiles/creator',
icon: Badge
}
],
[]
);
const currentDropdownItem = dropdownItems[0];
const menuItems = useMemo<MenuNode[]>(
() => [
{ label: 'Configuration' },
{ title: 'API Setup', path: '/account/api-keys' },
{ title: 'Team Settings', path: '/account/home/settings-sidebar' },
{ title: 'Authentication', path: '/authentication/classic/sign-in' },
{ title: 'Endpoints Configs', path: '/account/appearance' },
{ title: 'Rate Limiting', path: '/public-profile/network' },
{ label: 'Security' },
{ title: 'Data Encryption', path: '/account/billing/enterprise' },
{ title: 'Text', path: '/account/security/overview' },
{ title: 'Access Control', path: '/account/security/privacy-settings' },
{ label: 'Analytics' },
{
title: 'Incident Response',
path: '/account/security/current-sessions'
},
{ title: 'Fetching Data', path: '/account/members/team-info' },
{ title: 'Custom Reports', path: '/account/home/user-profile' },
{
title: 'Real Time Analytics',
path: '/account/home/settings-enterprise'
},
{ title: 'Exporting Data', path: '/account/members/import-members' },
{ title: 'Dashboard Integration', path: '/account/members/team-info' }
],
[] // Empty dependency array since the data is static
);
const classNames: AccordionMenuClassNames = {
root: 'space-y-1',
label: 'uppercase text-xs font-medium text-muted-foreground/80 pt-6 mb-2 pb-0',
item: 'h-8 hover:bg-background border-accent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium'
};
const matchPath = useCallback(
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
[pathname]
);
const buildDropdown = () => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
mode="input"
className="w-full justify-between focus-visible:ring-0 focus-visible:ring-offset-0"
>
<span className="flex items-center gap-2">
<currentDropdownItem.icon />
{currentDropdownItem.title}
</span>
<ChevronDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-(--radix-dropdown-menu-trigger-width)">
{dropdownItems.map((item, index) => (
<DropdownMenuItem key={index} asChild>
<Link to={item.path}>
<item.icon />
<span>{item.title}</span>
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
const memoizedMenu = useMemo(
() => (
<AccordionMenuGroup>
{menuItems.map((item, index) =>
'label' in item ? (
<AccordionMenuLabel key={index}>{item.label}</AccordionMenuLabel>
) : (
<AccordionMenuItem key={index} value={item.path || `item-${index}`} className="text-sm">
<Link to={item.path || '#'}>{item.title}</Link>
</AccordionMenuItem>
)
)}
</AccordionMenuGroup>
),
[menuItems]
);
const buildMenu = () => {
return (
<AccordionMenu
selectedValue={'/account/home/settings-sidebar'}
matchPath={matchPath}
type="single"
collapsible
classNames={classNames}
>
{memoizedMenu}
</AccordionMenu>
);
};
return (
<div className="w-full space-y-1">
{buildDropdown()}
{buildMenu()}
</div>
);
}
@@ -1,180 +0,0 @@
import { JSX, useCallback } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { MenuConfig, MenuItem } from '#app/config/types';
import { useMenuConfig } from '#app/menu';
import { useMenu } from '#shared/hooks/useMenu';
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
import { Badge } from '#shared/ui/Badge';
export function SidebarMenuDefault() {
const { pathname } = useLocation();
const menuConfig = useMenuConfig();
// Memoize matchPath to prevent unnecessary re-renders
const matchPath = useCallback(
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
[pathname]
);
// Global classNames for consistent styling
const classNames: AccordionMenuClassNames = {
root: 'lg:ps-1 space-y-1',
group: 'gap-px',
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
separator: '',
item: 'border border-transparent h-9 hover:bg-background text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium data-[selected=true]:border-border',
sub: '',
subTrigger:
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-transparent data-[selected=true]:font-medium',
subContent: 'ps-3 py-0',
indicator: ''
};
const buildMenu = (items: MenuConfig): JSX.Element[] => {
return items.map((item: MenuItem, index: number) => {
if (item.heading) {
return buildMenuHeading(item, index);
} else if (item.disabled) {
return buildMenuItemRootDisabled(item, index);
} else {
return buildMenuItemRoot(item, index);
}
});
};
const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
if (item.children) {
return (
<AccordionMenuSub key={index} value={item.path || `root-${index}`}>
<AccordionMenuSubTrigger className="text-sm font-medium">
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<span data-slot="accordion-menu-title">{item.title}</span>
</AccordionMenuSubTrigger>
<AccordionMenuSubContent
type="single"
collapsible
parentValue={item.path || `root-${index}`}
>
<AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
</AccordionMenuSubContent>
</AccordionMenuSub>
);
} else {
return (
<AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
<Link to={item.path || '#'} className="flex grow items-center justify-between gap-2">
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<span data-slot="accordion-menu-title">{item.title}</span>
</Link>
</AccordionMenuItem>
);
}
};
const buildMenuItemRootDisabled = (item: MenuItem, index: number): JSX.Element => {
return (
<AccordionMenuItem key={index} value={`disabled-${index}`} className="text-sm font-medium">
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<span data-slot="accordion-menu-title">{item.title}</span>
{item.disabled && (
<Badge variant="secondary" size="sm" className="ms-auto -me-2.5">
Soon
</Badge>
)}
</AccordionMenuItem>
);
};
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
return items.map((item: MenuItem, index: number) => {
if (item.disabled) {
return buildMenuItemChildDisabled(item, index, level);
} else {
return buildMenuItemChild(item, index, level);
}
});
};
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
if (item.children) {
return (
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
<AccordionMenuSubTrigger className="text-[13px]">
{item.collapse ? (
<span className="text-muted-foreground">
<span className="hidden [[data-state=open]>span>&]:inline">
{item.collapseTitle}
</span>
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
</span>
) : (
item.title
)}
</AccordionMenuSubTrigger>
<AccordionMenuSubContent
type="single"
collapsible
parentValue={item.path || `child-${level}-${index}`}
className={cn(
'ps-4',
!item.collapse && 'relative',
!item.collapse && (level > 0 ? '' : '')
)}
>
<AccordionMenuGroup>
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
</AccordionMenuGroup>
</AccordionMenuSubContent>
</AccordionMenuSub>
);
} else {
return (
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
<Link to={item.path || '#'}>{item.title}</Link>
</AccordionMenuItem>
);
}
};
const buildMenuItemChildDisabled = (
item: MenuItem,
index: number,
level: number = 0
): JSX.Element => {
return (
<AccordionMenuItem
key={index}
value={`disabled-child-${level}-${index}`}
className="text-[13px]"
>
<span data-slot="accordion-menu-title">{item.title}</span>
{item.disabled && (
<Badge variant="secondary" size="sm" className="ms-auto -me-2.5">
Soon
</Badge>
)}
</AccordionMenuItem>
);
};
const buildMenuHeading = (item: MenuItem, index: number): JSX.Element => {
return <AccordionMenuLabel key={index}>{item.heading}</AccordionMenuLabel>;
};
// Memoize the menu to prevent unnecessary re-renders
const { getChildren } = useMenu(pathname);
const menuItems = getChildren(menuConfig, 0);
return (
<AccordionMenu
selectedValue={pathname}
matchPath={matchPath}
type="single"
collapsible
classNames={classNames}
>
{buildMenu(menuItems || [])}
</AccordionMenu>
);
}
@@ -1,83 +0,0 @@
import { Fragment, ReactNode } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { MenuItem } from '#app/config/types';
import { useMenuConfig } from '#app/menu';
import { useMenu } from '#shared/hooks/useMenu';
import { Container } from '#shared/widgets/common/Container';
export interface ToolbarHeadingProps {
title?: string | ReactNode;
description?: string | ReactNode;
}
function Toolbar({ children }: { children?: ReactNode }) {
return (
<div className="mb-5 lg:mb-7.5">
<Container className="flex flex-wrap items-center justify-between gap-5">
{children}
</Container>
</div>
);
}
function ToolbarActions({ children }: { children?: ReactNode }) {
return <div className="flex items-center gap-2.5">{children}</div>;
}
function ToolbarBreadcrumbs() {
const menuConfig = useMenuConfig();
const { pathname } = useLocation();
const { getBreadcrumb, isActive } = useMenu(pathname);
const items: MenuItem[] = getBreadcrumb(menuConfig);
if (items.length === 0) {
return null;
}
return (
<div className="breadcrumb flex items-center gap-1 text-sm">
{items.map((item, index) => {
const isLast = index === items.length - 1;
const active = item.path ? isActive(item.path) : false;
return (
<Fragment key={index}>
{item.path ? (
<Link
to={item.path}
className={cn(
'flex items-center gap-1',
active ? 'text-mono' : 'text-secondary-foreground hover:text-primary'
)}
>
{item.title}
</Link>
) : (
<span className={cn(isLast ? 'text-mono' : 'text-secondary-foreground')}>
{item.title}
</span>
)}
{!isLast && <span className="text-muted-foreground">/</span>}
</Fragment>
);
})}
</div>
);
}
const ToolbarHeading = ({ title = '' }: ToolbarHeadingProps) => {
const { pathname } = useLocation();
const { getCurrentItem } = useMenu(pathname);
const menuConfig = useMenuConfig();
const item = getCurrentItem(menuConfig);
return (
<div className="flex flex-col flex-wrap gap-1 md:flex-row md:items-center lg:gap-5">
<h1 className="text-mono text-lg font-medium">{title || item?.title}</h1>
<ToolbarBreadcrumbs />
</div>
);
};
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
@@ -1,44 +1,24 @@
// DEMO 5 // DEMO 5
// https://keenthemes.com/metronic/tailwind/react/demo5 // https://keenthemes.com/metronic/tailwind/react/demo5
import { addDays, format } from 'date-fns'; import { Outlet } from 'react-router';
import { CalendarDays } from 'lucide-react';
import { useEffect, useState } from 'react';
import { DateRange } from 'react-day-picker';
import { Outlet, useLocation } from 'react-router';
import { useBodyClass, useIsMobile } from '@crux-kit/hooks'; import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types'; import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider'; import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
import { Button } from '#shared/ui/Button';
import { Calendar } from '#shared/ui/Calendar';
import { Popover, PopoverContent, PopoverTrigger } from '#shared/ui/Popover';
import { Footer } from './components/Footer';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { Navbar } from './components/Navbar'; import { Navbar } from './components/Navbar';
import { Sidebar } from './components/Sidebar'; import { Sidebar } from './components/Sidebar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export const DualRowsHeaderLayout = () => { export const DualRowsHeaderLayout = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { pathname } = useLocation(); useLayoutSettings(LayoutNames.DUAL_ROW_HEADER);
const { setOption } = useSettings(); const { showToolbar, showFooter } = useLayoutChrome();
const [date, setDate] = useState<DateRange | undefined>({
from: new Date(2025, 0, 20),
to: addDays(new Date(2025, 0, 20), 20)
});
// Using the custom hook to set multiple CSS variables and class properties
useBodyClass(` useBodyClass(`
[--header-height:54px] [--header-height:54px]
[--sidebar-width:200px] [--sidebar-width:200px]
`); `);
useEffect(() => {
setOption('layout', LayoutNames.DUAL_ROW_HEADER);
setOption('container', 'fluid');
}, [setOption]);
return ( return (
<> <>
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height)"> <div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height)">
@@ -50,44 +30,15 @@ export const DualRowsHeaderLayout = () => {
{!isMobile && <Sidebar />} {!isMobile && <Sidebar />}
<main className="flex grow flex-col"> <main className="flex grow flex-col">
{!pathname.includes('/public-profile/') && ( {showToolbar && (
<Toolbar> <Toolbar>
<ToolbarHeading /> <ToolbarHeading />
<ToolbarActions>
<Popover>
<PopoverTrigger asChild>
<Button id="date" variant="outline" mode="input">
<CalendarDays />
{date?.from ? (
date.to ? (
<span>
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
</span>
) : (
format(date.from, 'LLL dd, y')
)
) : (
<span>Pick a date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={setDate}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</ToolbarActions>
</Toolbar> </Toolbar>
)} )}
<Outlet /> <Outlet />
<Footer /> {showFooter && <AppFooter />}
</main> </main>
</div> </div>
</div> </div>
@@ -0,0 +1,24 @@
# Multiple Menus layout
**Metronic 9 reference:** [Demo 6](https://keenthemes.com/metronic/tailwind/react/demo6)
A fixed light sidebar holding the whole application menu as an accordion, with the page content in a
rounded card beside it. Below `lg` the sidebar collapses into a sheet opened from a compact header.
## Composition
| Piece | Source |
| ----------------------- | --------------------------------------------- |
| Sidebar menu | `SidebarAccordionMenu` from `../shared` |
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
| Footer | `AppFooter` from `../shared` |
| Account menu | `UserAvatarMenu` from `../shared` |
Layout-specific: `Sidebar`, `SidebarHeader` (logo plus search box), `SidebarFooter`, `Header`
(mobile sheet).
## Notes
- `RoosterPeek` is a development-only easter egg kept with this layout; it renders nothing in
production builds.
- Body variables: `--header-height: 60px`, `--sidebar-width: 270px`.
@@ -1,11 +1,10 @@
import { Menu } from 'lucide-react'; import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useIsMobile } from '@crux-kit/hooks';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button'; import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet'; import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { Container } from '#shared/widgets/common/Container'; import { Container } from '#shared/widgets/common/Container';
import { AppLogo } from '../../shared';
import { SidebarFooter } from './SidebarFooter'; import { SidebarFooter } from './SidebarFooter';
import { SidebarHeader } from './SidebarHeader'; import { SidebarHeader } from './SidebarHeader';
import { SidebarMenu } from './SidebarMenu'; import { SidebarMenu } from './SidebarMenu';
@@ -13,9 +12,7 @@ import { SidebarMenu } from './SidebarMenu';
const Header = () => { const Header = () => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const [isSheetOpen, setIsSheetOpen] = useState(false); const [isSheetOpen, setIsSheetOpen] = useState(false);
const isMobile = useIsMobile();
// Close sheet when route changes
useEffect(() => { useEffect(() => {
setIsSheetOpen(false); setIsSheetOpen(false);
}, [pathname]); }, [pathname]);
@@ -23,36 +20,23 @@ const Header = () => {
return ( return (
<header className="bg-muted fixed start-0 end-0 top-0 z-10 flex h-(--header-height) shrink-0 items-center lg:hidden"> <header className="bg-muted fixed start-0 end-0 top-0 z-10 flex h-(--header-height) shrink-0 items-center lg:hidden">
<Container className="flex flex-wrap items-center justify-between gap-3"> <Container className="flex flex-wrap items-center justify-between gap-3">
<Link to="/"> <AppLogo />
<img
src={toAbsoluteUrl('/media/app/logo-light.svg')}
className="h-8.5 dark:hidden"
alt="Logo"
/>
<img
src={toAbsoluteUrl('/media/app/logo-dark.svg')}
className="hidden h-8.5 dark:block"
alt="Logo"
/>
</Link>
{isMobile && ( <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}> <SheetTrigger asChild>
<SheetTrigger asChild> <Button variant="ghost" mode="icon">
<Button variant="ghost" mode="icon"> <Menu />
<Menu /> </Button>
</Button> </SheetTrigger>
</SheetTrigger> <SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}> <SheetHeader className="space-y-0 p-0" />
<SheetHeader className="space-y-0 p-0" /> <SheetBody className="flex grow flex-col p-0">
<SheetBody className="flex grow flex-col p-0"> <SidebarHeader />
<SidebarHeader /> <SidebarMenu />
<SidebarMenu /> <SidebarFooter />
<SidebarFooter /> </SheetBody>
</SheetBody> </SheetContent>
</SheetContent> </Sheet>
</Sheet>
)}
</Container> </Container>
</header> </header>
); );
@@ -1,25 +1,9 @@
import { IntlName } from '#infrastructure/IntlName'; import { UserAvatarMenu } from '../../shared';
import { useAuth } from '#core/Auth/providers/AuthProvider';
import { Avatar, AvatarFallback, AvatarImage } from '#shared/ui/Avatar';
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
export function SidebarFooter() { export function SidebarFooter() {
const { user } = useAuth();
return ( return (
<div className="flex-center flex h-14 shrink-0 justify-between ps-4 pe-3.5"> <div className="flex-center flex h-14 shrink-0 justify-between ps-4 pe-3.5">
{user && ( <UserAvatarMenu />
<UserDropdownMenu
trigger={
<Avatar className="size-9 cursor-pointer">
<AvatarImage src={undefined} alt="User avatar" />
<AvatarFallback className="text-sm">
{IntlName.formatToDisplay(user, 'INITIALS_LF')}
</AvatarFallback>
</Avatar>
}
/>
)}
</div> </div>
); );
} }
@@ -1,7 +1,6 @@
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { Link } from 'react-router-dom';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Input } from '#shared/ui/Input'; import { Input } from '#shared/ui/Input';
import { AppLogo } from '../../shared';
export function SidebarHeader() { export function SidebarHeader() {
const handleInputChange = () => {}; const handleInputChange = () => {};
@@ -9,18 +8,7 @@ export function SidebarHeader() {
return ( return (
<div className="mb-3.5"> <div className="mb-3.5">
<div className="flex h-17.5 items-center justify-between gap-2.5 px-3.5"> <div className="flex h-17.5 items-center justify-between gap-2.5 px-3.5">
<Link to="/"> <AppLogo className="h-10.5" />
<img
src={toAbsoluteUrl('/media/app/logo-light.svg')}
className="h-10.5 dark:hidden"
alt=""
/>
<img
src={toAbsoluteUrl('/media/app/logo-dark.svg')}
className="hidden h-10.5 dark:inline-block"
alt=""
/>
</Link>
<div className="shrink-0 dark:text-[#c08183]"> <div className="shrink-0 dark:text-[#c08183]">
C R U X <sub className="text-[50%]">Goat</sub> C R U X <sub className="text-[50%]">Goat</sub>
</div> </div>
@@ -1,9 +1,9 @@
import { SidebarMenuPrimary } from './SidebarMenuPrimary'; import { SidebarAccordionMenu } from '../../shared';
export function SidebarMenu() { export function SidebarMenu() {
return ( return (
<div className="kt-scrollable-y-auto max-h-[calc(100vh-11.5rem)] grow"> <div className="kt-scrollable-y-auto max-h-[calc(100vh-11.5rem)] grow">
<SidebarMenuPrimary /> <SidebarAccordionMenu />
</div> </div>
); );
} }
@@ -1,142 +0,0 @@
'use client';
import { JSX, useCallback } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { MenuConfig, MenuItem } from '#app/config/types';
import { useMenuConfig } from '#app/menu';
import { useAuth } from '#core/Auth/providers/AuthProvider';
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
import { getPermittedMenuItems } from '../lib';
export function SidebarMenuPrimary() {
const { pathname } = useLocation();
const { user } = useAuth();
const userPermissions = user?.permissions ?? [];
// Memoize matchPath to prevent unnecessary re-renders
const matchPath = useCallback(
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
[pathname]
);
// Global classNames for consistent styling
const classNames: AccordionMenuClassNames = {
root: 'space-y-2.5 px-3.5',
group: 'gap-px',
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
separator: '',
item: 'h-9 hover:bg-transparent border border-transparent text-accent-foreground hover:text-mono data-[selected=true]:text-mono data-[selected=true]:bg-background data-[selected=true]:border-border data-[selected=true]:font-medium',
sub: '',
subTrigger:
'h-9 hover:bg-transparent border border-transparent text-accent-foreground hover:text-mono data-[selected=true]:text-mono data-[selected=true]:bg-background data-[selected=true]:border-border data-[selected=true]:font-medium',
subContent: 'py-0',
indicator: ''
};
const menuConfig = useMenuConfig();
const buildMenu = (items: MenuConfig): JSX.Element[] => {
const menuItems = getPermittedMenuItems(items, userPermissions);
return menuItems.map((item: MenuItem, index: number) => {
if (!item.heading && !item.disabled) {
return buildMenuItemRoot(item, index);
} else {
return <></>;
}
});
};
const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
if (item.children) {
return (
<AccordionMenuSub key={index} value={item.path || `root-${index}`}>
<AccordionMenuSubTrigger className="text-sm font-medium">
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<span data-slot="accordion-menu-title">{item.title}</span>
</AccordionMenuSubTrigger>
<AccordionMenuSubContent
type="single"
collapsible
parentValue={item.path || `root-${index}`}
className="ps-6"
>
<AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
</AccordionMenuSubContent>
</AccordionMenuSub>
);
} else {
return (
<AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
<Link to={item.path || '#'}>
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<span data-slot="accordion-menu-title">{item.title}</span>
</Link>
</AccordionMenuItem>
);
}
};
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
return items.map((item: MenuItem, index: number) => {
if (!item.heading && !item.disabled) {
return buildMenuItemChild(item, index, level);
} else {
return <></>;
}
});
};
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
if (item.children) {
return (
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
<AccordionMenuSubTrigger className="text-[13px]">
{item.collapse ? (
<span className="text-muted-foreground">
<span className="hidden [[data-state=open]>span>&]:inline">
{item.collapseTitle}
</span>
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
</span>
) : (
item.title
)}
</AccordionMenuSubTrigger>
<AccordionMenuSubContent
type="single"
collapsible
parentValue={item.path || `child-${level}-${index}`}
className={cn(
'ps-4',
!item.collapse && 'relative',
!item.collapse && (level > 0 ? '' : '')
)}
>
<AccordionMenuGroup>
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
</AccordionMenuGroup>
</AccordionMenuSubContent>
</AccordionMenuSub>
);
} else {
return (
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
<Link to={item.path || '#'}>{item.title}</Link>
</AccordionMenuItem>
);
}
};
return (
<AccordionMenu
type="single"
selectedValue={pathname}
matchPath={matchPath}
collapsible
classNames={classNames}
>
{buildMenu(menuConfig)}
</AccordionMenu>
);
}
@@ -1,20 +1,18 @@
// DEMO 6 // DEMO 6
// https://keenthemes.com/metronic/tailwind/react/demo6 // https://keenthemes.com/metronic/tailwind/react/demo6
import React from 'react'; import { Fragment } from 'react';
import { Outlet, useLocation } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { useBodyClass, useIsMobile } from '@crux-kit/hooks'; import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types'; import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider'; import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
import { Footer } from './components/Footer';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar'; import { Sidebar } from './components/Sidebar';
import { Toolbar, ToolbarHeading } from './components/Toolbar';
export const MultipleMenusLayout = () => { export const MultipleMenusLayout = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { setOption } = useSettings(); useLayoutSettings(LayoutNames.MULTIPLE_MENUS);
const { pathname } = useLocation(); const { showToolbar, showFooter } = useLayoutChrome();
useBodyClass(` useBodyClass(`
[--header-height:60px] [--header-height:60px]
@@ -23,29 +21,8 @@ export const MultipleMenusLayout = () => {
bg-muted! bg-muted!
`); `);
React.useEffect(() => {
setOption('layout', LayoutNames.MULTIPLE_MENUS);
}, [setOption]);
const toolbar = React.useMemo(() => {
if (pathname.includes('/devtools')) return null;
if (pathname.includes('/access-control')) return null;
if (pathname.includes('/file-manager')) return null;
return (
<Toolbar>
<ToolbarHeading />
</Toolbar>
);
}, [pathname]);
const footer = React.useMemo(() => {
if (pathname.includes('/file-manager')) return null;
return <Footer />;
}, [pathname]);
return ( return (
<React.Fragment> <Fragment>
<div className="flex grow"> <div className="flex grow">
{!isMobile && <Sidebar />} {!isMobile && <Sidebar />}
@@ -55,14 +32,18 @@ export const MultipleMenusLayout = () => {
<div className="bg-background border-input m-3.75 mt-0 flex grow flex-col items-stretch overflow-hidden rounded-xl border lg:ms-(--sidebar-width) lg:mt-3.75"> <div className="bg-background border-input m-3.75 mt-0 flex grow flex-col items-stretch overflow-hidden rounded-xl border lg:ms-(--sidebar-width) lg:mt-3.75">
<div className="kt-scrollable-y-auto flex grow flex-col pt-5 [--kt-scrollbar-width:auto]"> <div className="kt-scrollable-y-auto flex grow flex-col pt-5 [--kt-scrollbar-width:auto]">
<main className="grow" role="content"> <main className="grow" role="content">
{toolbar} {showToolbar && (
<Toolbar>
<ToolbarHeading />
</Toolbar>
)}
<Outlet /> <Outlet />
</main> </main>
</div> </div>
{footer} {showFooter && <AppFooter />}
</div> </div>
</div> </div>
</div> </div>
</React.Fragment> </Fragment>
); );
}; };
@@ -1,25 +0,0 @@
import { MenuItem } from '#app/config/types';
export function getPermittedMenuItems(items: MenuItem[], userPermission: string[]): MenuItem[] {
return items
.map((item) => {
const filteredChildren = item.children
? getPermittedMenuItems(item.children, userPermission)
: undefined;
const hasPermission = item.permissions
? item.permissions.every((permission) => userPermission.includes(permission))
: true;
if (hasPermission || (filteredChildren && filteredChildren.length > 0)) {
return {
...item,
children: filteredChildren
};
}
return null;
})
.filter((item) => item != null)
.filter((item) => (item.children ? item.children.length > 0 : true));
}
@@ -0,0 +1,25 @@
# Advanced Mega Menu layout
**Metronic 9 reference:** [Demo 7](https://keenthemes.com/metronic/tailwind/react/demo7)
A header-only layout: the whole application menu lives in a mega menu beside the logo, and the page
content runs full width beneath it. The header becomes sticky and shrinks past
`layouts.advanced-mega-menu-layout.headerStickyOffset`.
## Composition
| Piece | Source |
| ----------------------- | --------------------------------------------- |
| Mega menu | `MegaNavMenu` from `../shared` |
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
| Footer | `AppFooter` from `../shared` |
| Account menu | `UserAvatarMenu` from `../shared` |
Layout-specific: `Header`, `HeaderLogo`.
## Notes
- The mega menu panels show two levels — a section's children as columns, their children as links.
Deeper entries stay reachable through the toolbar breadcrumbs and the mobile accordion.
- The `data-sticky-header` body attribute is removed on unmount.
@@ -1,42 +0,0 @@
import { generalSettings } from '#app/config/general';
import { Container } from '#shared/widgets/common/Container';
export function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="footer">
<Container>
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
<span className="text-muted-foreground">{currentYear} &copy;</span>
<a
href="https://keenthemes.com"
target="_blank"
className="text-secondary-foreground hover:text-primary"
>
Keenthemes Inc.
</a>
</div>
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
Docs
</a>
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
Purchase
</a>
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
FAQ
</a>
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
Support
</a>
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
License
</a>
</nav>
</div>
</Container>
</footer>
);
}
@@ -4,39 +4,42 @@ import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types'; import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider'; import { useSettings } from '#app/providers/SettingsProvider';
import { Container } from '#shared/widgets/common/Container'; import { Container } from '#shared/widgets/common/Container';
import { UserAvatarMenu } from '../../shared';
import { HeaderLogo } from './HeaderLogo'; import { HeaderLogo } from './HeaderLogo';
import { HeaderTopbar } from './HeaderTopbar';
const Header = () => { const Header = () => {
const { settings } = useSettings(); const { settings } = useSettings();
const scrollPosition = useScrollPosition(); const scrollPosition = useScrollPosition();
const [headerStickyOn, setHeaderStickyOn] = useState(false); const [headerStickyOn, setHeaderStickyOn] = useState(false);
useEffect(() => { const { headerSticky, headerStickyOffset } = settings.layouts[LayoutNames.ADVANCE_MEGA_MENU];
const isSticky =
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
setHeaderStickyOn(isSticky);
}, [scrollPosition, settings]);
useEffect(() => { useEffect(() => {
if (headerStickyOn === true) { setHeaderStickyOn(scrollPosition > headerStickyOffset);
}, [scrollPosition, headerStickyOffset]);
useEffect(() => {
if (headerStickyOn) {
document.body.setAttribute('data-sticky-header', 'on'); document.body.setAttribute('data-sticky-header', 'on');
} else { } else {
document.body.removeAttribute('data-sticky-header'); document.body.removeAttribute('data-sticky-header');
} }
return () => document.body.removeAttribute('data-sticky-header');
}, [headerStickyOn]); }, [headerStickyOn]);
return ( return (
<header <header
className={cn( className={cn(
'bg-background flex shrink-0 items-center py-4 transition-[height] lg:h-(--header-height) lg:py-0', 'bg-background flex shrink-0 items-center py-4 transition-[height] lg:h-(--header-height) lg:py-0',
headerStickyOn && headerSticky &&
headerStickyOn &&
'bg-background/70 fixed top-0 right-0 left-0 z-10 pe-(--removed-body-scroll-bar-size,0px) shadow-xs backdrop-blur-md transition-[height]' 'bg-background/70 fixed top-0 right-0 left-0 z-10 pe-(--removed-body-scroll-bar-size,0px) shadow-xs backdrop-blur-md transition-[height]'
)} )}
> >
<Container className="flex flex-wrap items-center gap-2 lg:gap-4"> <Container className="flex flex-wrap items-center gap-2 lg:gap-4">
<HeaderLogo /> <HeaderLogo />
<HeaderTopbar /> <UserAvatarMenu />
</Container> </Container>
</header> </header>
); );
@@ -1,12 +1,10 @@
import { Menu } from 'lucide-react'; import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useIsMobile } from '@crux-kit/hooks'; import { useIsMobile } from '@crux-kit/hooks';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button'; import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet'; import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { MegaMenu } from './MegaMenu'; import { AppLogo, AppWordmark, MegaNavMenu, SidebarAccordionMenu } from '../../shared';
import { MegaMenuMobile } from './MegaMenuMobile';
const HeaderLogo = () => { const HeaderLogo = () => {
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -20,24 +18,12 @@ const HeaderLogo = () => {
return ( return (
<div className="flex grow items-stretch gap-1.5 lg:gap-10"> <div className="flex grow items-stretch gap-1.5 lg:gap-10">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Link to="/"> <AppLogo className="size-8.5" />
<img <AppWordmark className="text-mono hidden text-lg font-medium lg:block" />
src={toAbsoluteUrl('/media/app/mini-logo-circle-primary.svg')}
className="min-h-8.5 dark:hidden"
alt="logo"
/>
<img
src={toAbsoluteUrl('/media/app/mini-logo-circle-primary-dark.svg')}
className="hidden min-h-8.5 dark:inline-block"
alt="logo"
/>
</Link>
<h3 className="text-mono hidden text-lg font-medium lg:block">Metronic</h3>
</div> </div>
{!isMobile ? ( {!isMobile ? (
<MegaMenu /> <MegaNavMenu />
) : ( ) : (
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}> <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
@@ -47,8 +33,8 @@ const HeaderLogo = () => {
</SheetTrigger> </SheetTrigger>
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}> <SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" /> <SheetHeader className="space-y-0 p-0" />
<SheetBody className="flex grow flex-col p-0"> <SheetBody className="flex grow flex-col overflow-y-auto py-5">
<MegaMenuMobile /> <SidebarAccordionMenu />
</SheetBody> </SheetBody>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -1,54 +0,0 @@
import { ArrowUp, Crown } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button } from '#shared/ui/Button';
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
const HeaderTopbar = () => {
return (
<div className="flex flex-wrap items-center gap-2 lg:gap-3.5">
<div className="flex items-center gap-2.5 lg:gap-3.5">
<div className="flex flex-col items-end pt-0.5">
<span className="text-foreground inline-flex gap-0.5 text-xs font-medium uppercase">
<Crown className="size-3.5" />
free
</span>
<span className="text-secondary-foreground text-xs">
182/200 <span className="hidden sm:inline-block">Uploads</span>
</span>
</div>
<Button size="sm" asChild>
<Link to="/account/security/privacy-settings">
Upgrade
<ArrowUp />
</Link>
</Button>
</div>
<div className="border-border mx-1.5 h-5 border-e lg:mx-3"></div>
<UserDropdownMenu
trigger={
<div className="border-input bg-muted relative inline-flex size-8.5 cursor-pointer items-center justify-center rounded-full border text-lg font-medium">
S
<svg
xmlns="http://www.w3.org/2000/svg"
width="15"
height="16"
viewBox="0 0 15 16"
fill="none"
className="text-primary absolute -top-0.5 left-6 size-3"
>
<path
d="M14.5425 6.89749L13.5 5.83999C13.4273 5.76877 13.3699 5.6835 13.3312 5.58937C13.2925 5.49525 13.2734 5.39424 13.275 5.29249V3.79249C13.274 3.58699 13.2324 3.38371 13.1527 3.19432C13.0729 3.00494 12.9565 2.83318 12.8101 2.68892C12.6638 2.54466 12.4904 2.43073 12.2998 2.35369C12.1093 2.27665 11.9055 2.23801 11.7 2.23999H10.2C10.0982 2.24159 9.99722 2.22247 9.9031 2.18378C9.80898 2.1451 9.72371 2.08767 9.65249 2.01499L8.60249 0.957487C8.30998 0.665289 7.91344 0.50116 7.49999 0.50116C7.08654 0.50116 6.68999 0.665289 6.39749 0.957487L5.33999 1.99999C5.26876 2.07267 5.1835 2.1301 5.08937 2.16879C4.99525 2.20747 4.89424 2.22659 4.79249 2.22499H3.29249C3.08699 2.22597 2.88371 2.26754 2.69432 2.34731C2.50494 2.42709 2.33318 2.54349 2.18892 2.68985C2.04466 2.8362 1.93073 3.00961 1.85369 3.20013C1.77665 3.39064 1.73801 3.5945 1.73999 3.79999V5.29999C1.74159 5.40174 1.72247 5.50275 1.68378 5.59687C1.6451 5.691 1.58767 5.77627 1.51499 5.84749L0.457487 6.89749C0.165289 7.19 0.00115967 7.58654 0.00115967 7.99999C0.00115967 8.41344 0.165289 8.80998 0.457487 9.10249L1.49999 10.16C1.57267 10.2312 1.6301 10.3165 1.66878 10.4106C1.70747 10.5047 1.72659 10.6057 1.72499 10.7075V12.2075C1.72597 12.413 1.76754 12.6163 1.84731 12.8056C1.92709 12.995 2.04349 13.1668 2.18985 13.3111C2.3362 13.4553 2.50961 13.5692 2.70013 13.6463C2.89064 13.7233 3.0945 13.762 3.29999 13.76H4.79999C4.90174 13.7584 5.00275 13.7775 5.09687 13.8162C5.191 13.8549 5.27627 13.9123 5.34749 13.985L6.40499 15.0425C6.69749 15.3347 7.09404 15.4988 7.50749 15.4988C7.92094 15.4988 8.31748 15.3347 8.60999 15.0425L9.65999 14C9.73121 13.9273 9.81647 13.8699 9.9106 13.8312C10.0047 13.7925 10.1057 13.7734 10.2075 13.775H11.7075C12.1212 13.775 12.518 13.6106 12.8106 13.3181C13.1031 13.0255 13.2675 12.6287 13.2675 12.215V10.715C13.2659 10.6132 13.285 10.5122 13.3237 10.4181C13.3624 10.324 13.4198 10.2387 13.4925 10.1675L14.55 9.10999C14.6953 8.96452 14.8104 8.79176 14.8887 8.60164C14.9671 8.41152 15.007 8.20779 15.0063 8.00218C15.0056 7.79656 14.9643 7.59311 14.8847 7.40353C14.8051 7.21394 14.6888 7.04197 14.5425 6.89749ZM10.635 6.64999L6.95249 10.25C6.90055 10.3026 6.83864 10.3443 6.77038 10.3726C6.70212 10.4009 6.62889 10.4153 6.55499 10.415C6.48062 10.4139 6.40719 10.3982 6.33896 10.3685C6.27073 10.3389 6.20905 10.2961 6.15749 10.2425L4.37999 8.44249C4.32532 8.39044 4.28169 8.32793 4.25169 8.25867C4.22169 8.18941 4.20593 8.11482 4.20536 8.03934C4.20479 7.96387 4.21941 7.88905 4.24836 7.81934C4.27731 7.74964 4.31999 7.68647 4.37387 7.63361C4.42774 7.58074 4.4917 7.53926 4.56194 7.51163C4.63218 7.484 4.70726 7.47079 4.78271 7.47278C4.85816 7.47478 4.93244 7.49194 5.00112 7.52324C5.0698 7.55454 5.13148 7.59935 5.18249 7.65499L6.56249 9.05749L9.84749 5.84749C9.95296 5.74215 10.0959 5.68298 10.245 5.68298C10.394 5.68298 10.537 5.74215 10.6425 5.84749C10.6953 5.90034 10.737 5.96318 10.7653 6.03234C10.7935 6.1015 10.8077 6.1756 10.807 6.25031C10.8063 6.32502 10.7908 6.39884 10.7612 6.46746C10.7317 6.53608 10.6888 6.59813 10.635 6.64999Z"
fill="currentColor"
/>
</svg>
</div>
}
/>
</div>
);
};
export { HeaderTopbar };
@@ -1,114 +0,0 @@
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { MenuConfig } from '#app/config/types';
import { useMenu } from '#shared/hooks/useMenu';
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger } from '#shared/ui/NavigationMenu';
import { MegaMenuSubAccount } from '#shared/widgets/MegaMenu/MegaMenuSubAccount';
import { MegaMenuSubAuth } from '#shared/widgets/MegaMenu/MegaMenuSubAuth';
import { MegaMenuSubNetwork } from '#shared/widgets/MegaMenu/MegaMenuSubNetwork';
import { MegaMenuSubProfiles } from '#shared/widgets/MegaMenu/MegaMenuSubProfiles';
import { MegaMenuSubStore } from '#shared/widgets/MegaMenu/MegaMenuSubStore';
const MENU_MEGA: MenuConfig = [];
export function MegaMenu() {
const { pathname } = useLocation();
const { isActive, hasActiveChild } = useMenu(pathname);
const homeItem = MENU_MEGA[0];
const publicProfilesItem = MENU_MEGA[1];
const myAccountItem = MENU_MEGA[2];
const networkItem = MENU_MEGA[3];
const storeItem = MENU_MEGA[5];
const authItem = MENU_MEGA[4];
const linkClass = `
text-sm text-secondary-foreground font-medium rounded-none px-0 border-b border-transparent
hover:text-primary hover:bg-transparent
focus:text-primary focus:bg-transparent
data-[active=true]:text-mono data-[active=true]:bg-transparent data-[active=true]:border-mono
data-[state=open]:text-mono data-[state=open]:bg-transparent
`;
return (
<NavigationMenu>
<NavigationMenuList className="gap-7.5">
{/* Home Item */}
<NavigationMenuItem>
<NavigationMenuLink asChild>
<Link
to={homeItem.path || '/'}
className={cn(linkClass)}
data-active={isActive(homeItem.path) || undefined}
>
{homeItem.title}
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
{/* Public Profiles Item */}
<NavigationMenuItem>
<NavigationMenuTrigger
className={cn(linkClass)}
data-active={hasActiveChild(publicProfilesItem.children) || undefined}
>
{publicProfilesItem.title}
</NavigationMenuTrigger>
<NavigationMenuContent className="p-0">
<MegaMenuSubProfiles items={MENU_MEGA} />
</NavigationMenuContent>
</NavigationMenuItem>
{/* My Account Item */}
<NavigationMenuItem>
<NavigationMenuTrigger
className={cn(linkClass)}
data-active={hasActiveChild(myAccountItem.children) || undefined}
>
{myAccountItem.title}
</NavigationMenuTrigger>
<NavigationMenuContent className="p-0">
<MegaMenuSubAccount items={MENU_MEGA} />
</NavigationMenuContent>
</NavigationMenuItem>
{/* Network Item */}
<NavigationMenuItem>
<NavigationMenuTrigger
className={cn(linkClass)}
data-active={hasActiveChild(networkItem.children || []) || undefined}
>
{networkItem.title}
</NavigationMenuTrigger>
<NavigationMenuContent className="p-0">
<MegaMenuSubNetwork items={MENU_MEGA} />
</NavigationMenuContent>
</NavigationMenuItem>
{/* Store Item */}
<NavigationMenuItem>
<NavigationMenuTrigger
className={cn(linkClass)}
data-active={hasActiveChild(storeItem.children || []) || undefined}
>
{storeItem.title}
</NavigationMenuTrigger>
<NavigationMenuContent className="p-0">
<MegaMenuSubStore items={MENU_MEGA} />
</NavigationMenuContent>
</NavigationMenuItem>
{/* Authentication Item */}
<NavigationMenuItem>
<NavigationMenuTrigger
className={cn(linkClass)}
data-active={hasActiveChild(authItem.children) || undefined}
>
{authItem.title}
</NavigationMenuTrigger>
<NavigationMenuContent className="p-0">
<MegaMenuSubAuth items={MENU_MEGA} />
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
);
}
@@ -1,188 +0,0 @@
'use client';
import { LucideIcon } from 'lucide-react';
import { JSX, useCallback } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
import { Badge } from '#shared/ui/Badge';
export interface MenuItem {
title?: string;
icon?: LucideIcon;
path?: string;
rootPath?: string;
childrenIndex?: number;
heading?: string;
children?: MenuItem[];
disabled?: boolean;
collapse?: boolean;
collapseTitle?: string;
expandTitle?: string;
badge?: string;
separator?: boolean;
}
export type MenuConfig = MenuItem[];
const MENU_MEGA_MOBILE: MenuConfig = [];
export function MegaMenuMobile() {
const { pathname } = useLocation();
// Memoize matchPath to prevent unnecessary re-renders
const matchPath = useCallback(
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
[pathname]
);
// Global classNames for consistent styling
const classNames: AccordionMenuClassNames = {
root: 'space-y-1',
group: 'gap-px',
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
separator: '',
item: 'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
sub: '',
subTrigger:
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
subContent: 'py-0',
indicator: ''
};
const buildMenu = (items: MenuConfig): JSX.Element[] => {
return items.map((item: MenuItem, index: number) => {
if (item.heading) {
return buildMenuHeading(item, index);
} else if (!item.disabled) {
return buildMenuItemRoot(item, index);
} else {
return <></>;
}
});
};
const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
if (item.children) {
return (
<AccordionMenuSub key={index} value={item.path || `root-${index}`}>
<AccordionMenuSubTrigger className="text-sm font-medium">
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<div className="flex grow items-center justify-between gap-2">
<span data-slot="accordion-menu-title">{item.title}</span>
{item.badge && (
<Badge variant="secondary" size="sm" className="ms-auto">
{item.badge}
</Badge>
)}
</div>
</AccordionMenuSubTrigger>
<AccordionMenuSubContent
type="single"
collapsible
parentValue={item.path || `root-${index}`}
className="ps-6"
>
<AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
</AccordionMenuSubContent>
</AccordionMenuSub>
);
} else {
return (
<AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
<Link to={item.path || '#'} className="">
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<div className="flex grow items-center justify-between gap-2">
<span data-slot="accordion-menu-title">{item.title}</span>
{item.badge && (
<Badge variant="secondary" size="sm" className="ms-auto">
{item.badge}
</Badge>
)}
</div>
</Link>
</AccordionMenuItem>
);
}
};
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
return items.map((item: MenuItem, index: number) => {
if (!item.disabled) {
return buildMenuItemChild(item, index, level);
} else {
return <></>;
}
});
};
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
if (item.children) {
return (
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
<AccordionMenuSubTrigger className="text-[13px]">
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
{item.collapse ? (
<span className="text-muted-foreground">
<span className="hidden [[data-state=open]>span>&]:inline">
{item.collapseTitle}
</span>
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
</span>
) : (
item.title
)}
{item.badge && (
<Badge variant="secondary" size="sm" className="ms-auto">
{item.badge}
</Badge>
)}
</AccordionMenuSubTrigger>
<AccordionMenuSubContent
type="single"
collapsible
parentValue={item.path || `child-${level}-${index}`}
className={cn('ps-4', !item.collapse && 'relative')}
>
<AccordionMenuGroup>
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
</AccordionMenuGroup>
</AccordionMenuSubContent>
</AccordionMenuSub>
);
} else {
return (
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
<Link to={item.path || '#'}>
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
<div className="flex grow items-center justify-between gap-2">
<span>{item.title}</span>
{item.badge && (
<Badge variant="secondary" size="sm" className="ms-auto">
{item.badge}
</Badge>
)}
</div>
</Link>
</AccordionMenuItem>
);
}
};
const buildMenuHeading = (item: MenuItem, index: number): JSX.Element => {
return <AccordionMenuLabel key={index}>{item.heading}</AccordionMenuLabel>;
};
return (
<div className="flex shrink-0 grow px-5 py-5">
<AccordionMenu
selectedValue={pathname}
matchPath={matchPath}
type="single"
collapsible
classNames={classNames}
>
{buildMenu(MENU_MEGA_MOBILE)}
</AccordionMenu>
</div>
);
}
@@ -1,87 +0,0 @@
import { Fragment, ReactNode } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { MenuItem } from '#app/config/types';
import { useMenuConfig } from '#app/menu';
import { useMenu } from '#shared/hooks/useMenu';
import { Container } from '#shared/widgets/common/Container';
export interface ToolbarHeadingProps {
title?: string | ReactNode;
description?: string | ReactNode;
}
function Toolbar({ children }: { children?: ReactNode }) {
return (
<Container>
<div className="border-border border-t"></div>
<div className="la:gap-5 my-5 flex flex-wrap items-center justify-between gap-2">
{children}
</div>
<div className="border-border mb-5 border-b lg:mb-7.5"></div>
</Container>
);
}
function ToolbarActions({ children }: { children?: ReactNode }) {
return <div className="flex flex-wrap items-center gap-2.5">{children}</div>;
}
function ToolbarBreadcrumbs() {
const { pathname } = useLocation();
const { getBreadcrumb, isActive } = useMenu(pathname);
const menuConfig = useMenuConfig();
const items: MenuItem[] = getBreadcrumb(menuConfig);
if (items.length === 0) {
return null;
}
return (
<div className="flex items-center gap-1 text-sm">
{items.map((item, index) => {
const isLast = index === items.length - 1;
const active = item.path ? isActive(item.path) : false;
return (
<Fragment key={index}>
{item.path ? (
<Link
to={item.path}
className={cn(
'flex items-center gap-1',
active ? 'text-mono' : 'text-secondary-foreground hover:text-primary'
)}
>
{item.title}
</Link>
) : (
<span className={cn(isLast ? 'text-mono' : 'text-secondary-foreground')}>
{item.title}
</span>
)}
{!isLast && <span className="text-muted-foreground">/</span>}
</Fragment>
);
})}
</div>
);
}
function ToolbarHeading({ title = '' }: ToolbarHeadingProps) {
const { pathname } = useLocation();
const { getCurrentItem } = useMenu(pathname);
const menuConfig = useMenuConfig();
const item = getCurrentItem(menuConfig);
return (
<div className="flex flex-col gap-1">
<h1 className="text-mono text-lg font-medium">{title || item?.title}</h1>
<ToolbarBreadcrumbs />
</div>
);
}
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
@@ -1,31 +1,16 @@
// DEMO 7 // DEMO 7
// https://keenthemes.com/metronic/tailwind/react/demo7 // https://keenthemes.com/metronic/tailwind/react/demo7
import { addDays, format } from 'date-fns'; import { Outlet } from 'react-router-dom';
import { CalendarDays, Download } from 'lucide-react';
import { useEffect, useState } from 'react';
import { DateRange } from 'react-day-picker';
import { Link, Outlet, useLocation } from 'react-router-dom';
import { useBodyClass } from '@crux-kit/hooks'; import { useBodyClass } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types'; import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider'; import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
import { Button } from '#shared/ui/Button';
import { Calendar } from '#shared/ui/Calendar';
import { Popover, PopoverContent, PopoverTrigger } from '#shared/ui/Popover';
import { Footer } from './components/Footer';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export const AdvancedMegaMenuLayout = () => { export const AdvancedMegaMenuLayout = () => {
const { setOption } = useSettings(); useLayoutSettings(LayoutNames.ADVANCE_MEGA_MENU);
const { pathname } = useLocation(); const { showToolbar, showFooter } = useLayoutChrome();
const [date, setDate] = useState<DateRange | undefined>({
from: new Date(2025, 0, 20),
to: addDays(new Date(2025, 0, 20), 20)
});
// Using the custom hook to set multiple CSS variables and class properties
useBodyClass(` useBodyClass(`
[--header-height-default:95px] [--header-height-default:95px]
data-[sticky-header=on]:[--header-height:60px] data-[sticky-header=on]:[--header-height:60px]
@@ -33,62 +18,21 @@ export const AdvancedMegaMenuLayout = () => {
[--header-height-mobile:70px] [--header-height-mobile:70px]
`); `);
useEffect(() => {
setOption('layout', LayoutNames.ADVANCE_MEGA_MENU);
}, [setOption]);
return ( return (
<> <>
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height-default)"> <div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height-default)">
<Header /> <Header />
<div className="grow" role="content"> <main className="grow" role="content">
{!pathname.includes('/public-profile/') && ( {showToolbar && (
<Toolbar> <Toolbar>
<ToolbarHeading /> <ToolbarHeading />
<ToolbarActions>
<Button variant="outline" asChild>
<Link to={'/account/home/get-started'}>
<Download />
Export
</Link>
</Button>
<Popover>
<PopoverTrigger asChild>
<Button id="date" variant="outline">
<CalendarDays />
{date?.from ? (
date.to ? (
<span>
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
</span>
) : (
format(date.from, 'LLL dd, y')
)
) : (
<span>Pick a date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={setDate}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</ToolbarActions>
</Toolbar> </Toolbar>
)} )}
<Outlet /> <Outlet />
</div> </main>
<Footer /> {showFooter && <AppFooter />}
</div> </div>
</> </>
); );
@@ -0,0 +1,24 @@
# Dropdown Menu layout
**Metronic 9 reference:** [Demo 8](https://keenthemes.com/metronic/tailwind/react/demo8)
A 90px sidebar of labelled icon tiles, one per top-level section. A section with children opens its
whole subtree in a nested dropdown rather than expanding in place.
## Composition
| Piece | Source |
| --------------------------- | --------------------------------------------- |
| Sidebar tiles and dropdowns | `useAppMenu` from `../shared` |
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
| Footer | `AppFooter` from `../shared` |
| Account menu | `UserAvatarMenu` from `../shared` |
Layout-specific: `Sidebar`, `SidebarHeader`, `SidebarMenu`, `SidebarFooter`, `Header`.
## Notes
- Below `lg` the tiles give way to the shared accordion in a sheet, which reads better on a narrow
viewport than nested dropdowns.
- Body variables: `--header-height: 60px`, `--sidebar-width: 90px`.
@@ -1,42 +0,0 @@
import { generalSettings } from '#app/config/general';
import { Container } from '#shared/widgets/common/Container';
export function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="footer">
<Container>
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
<span className="text-muted-foreground">{currentYear} &copy;</span>
<a
href="https://keenthemes.com"
target="_blank"
className="text-secondary-foreground hover:text-primary"
>
Keenthemes Inc.
</a>
</div>
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
Docs
</a>
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
Purchase
</a>
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
FAQ
</a>
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
Support
</a>
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
License
</a>
</nav>
</div>
</Container>
</footer>
);
}

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