Compare commits

...
18 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
Igor Martyniuk fdb2cf3837 Merge branch 'feature/tiptap-editor' into 'main'
Feature: Add TipTap editor package

See merge request crux/admin!29
2026-07-31 12:41:47 +00:00
Igor Martyniuk 1d51ece993 Feature: Add TipTap editor package 2026-07-31 12:41:47 +00:00
Igor Martyniuk 827dc22729 docs: update changelog for v1.2.0 2026-07-17 17:16:35 +03:00
Igor Martyniuk 4f7003dde9 Merge branch 'structure/rework-backend' into 'main'
Reorganize app workspaces and align Admin architecture

See merge request crux/admin!28
2026-07-17 13:46:21 +00:00
Igor Martyniuk 0ba01cd111 Reorganize app workspaces and align Admin architecture 2026-07-17 13:46:21 +00:00
kossmc ba691d3a0c docs: update changelog for v1.1.0 2026-07-17 11:54:26 +03:00
Igor Martyniuk fe258e5895 Merge branch 'ad7' into 'main'
Feature: Migrate  to Typescript7 & AdonisJs7

See merge request crux/admin!27
2026-07-17 08:05:15 +00:00
Igor Martyniuk bbdf4a3312 Feature: Migrate to Typescript7 & AdonisJs7 2026-07-17 08:05:15 +00:00
Igor Martyniuk e98a1e85f0 Merge branch 'feature/file-manager-image-map' into 'main'
Feature/add image map editor

See merge request crux/admin!26
2026-07-10 08:47:27 +00:00
dmytro 2232ef0029 Feature/add image map editor 2026-07-10 08:47:27 +00:00
1036 changed files with 37682 additions and 17659 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules
.next
npm-debug.log
.git
+4 -2
View File
@@ -26,5 +26,7 @@ yarn-error.log
# Platform specific
.DS_Store
/backend/public/
/backend/storage/
*.tsbuildinfo
/apps/backend/public/
/apps/backend/storage/
+2
View File
@@ -1 +1,3 @@
npm run test:admin
npm run test:backend
npm run test:web
+1
View File
@@ -0,0 +1 @@
24.18.0
+13
View File
@@ -0,0 +1,13 @@
**/node_modules/**
**/build/**
**/dist/**
**/coverage/**
**/.next/**
apps/backend/public/**
apps/backend/storage/**
apps/backend/app/templates/**
apps/backend/.adonisjs/**
apps/backend/database/schema.ts
apps/web/.next/**
+107
View File
@@ -7,15 +7,122 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
### Changed
- Rebuilt the nine non-default layouts against the application's own data.
### Fixed
### 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
### Changed
- Moved AdonisJS ESLint and Prettier configuration packages and Japa test dependencies from the
root package manifest to the backend workspace so workspace-specific dependencies are owned by
`apps/backend`.
- Updated the Admin frontend architecture so global providers belong to the `app` composition
layer under `app/providers` instead of being modeled as a separate architectural layer.
## [1.1.0] - 2026-07-17
### Added
- Added image map editor for creating and editing interactive image map areas in the File Manager.
### Changed
- Migrated TypeScript from `5.8.3` to `7.0.2`
- Migrated AdonisJS from `6.x` to `7.3.5`
- Refactored admin module-generator source builders to compose imports and source sections through
shared source-file helpers while preserving the existing generation payload.
- Renamed module-generator builder classes and files from template-builder terminology to
source-builder terminology.
- Updated module-generator frontend documentation to reference the new source-builder names.
### Fixed
- Fix prettier one line import break sorting and class order
### Removed
- Removed "prettier-plugin-one-line-imports"
## [1.0.1] - 2026-06-30
+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
#### Backend
@@ -6,94 +21,157 @@
- TypeScript
- Lucid ORM
- PostgreSQL / MySQL (configurable)
- Redis, S3 / MinIO storage
#### Frontend
#### Admin panel
- React
- TypeScript
- Vite
- React Query / Mobx / Axios
- Component-based UI system
- React Query / MobX / Axios
- 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
#### Prerequisites
Node.js >= 22.14.0
npm >= 11.6.2
Database PostgreSQL
- Node.js `24.18.0` (see `.nvmrc`; the package manifest requires `>=24.0.0`)
- npm `>=11.6.2` (the repo pins `npm@11.15.0`)
- PostgreSQL
#### Installation
```
```bash
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
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:
```
TZ=UTC
PORT=3333
HOST=localhost
LOG_LEVEL=info
APP_KEY=
NODE_ENV=development
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_DATABASE=adonis
VITE_API_URL=http://0.0.0.0:3333
VITE_TINYMCE_API_KEY=
| Env file | Covers |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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 |
| `apps/admin/.env` | `VITE_API_URL` (backend), `VITE_WEB_URL` (public site, used for page previews), `VITE_TINYMCE_API_KEY` |
| `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) |
`docker-compose.yml` loads each service's env from the same per-app files. Inside Docker, service
URLs must use compose service names (`http://backend:3333`), not `localhost`.
### Running
#### Locally
| 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
- Fields and types
- Validation rules
- List view configuration
#### Generated Output
**Generated output** — Backend: controllers, validators, routes. Frontend: pages, forms, tables, API.
**Backend**: controllers, validators, routes
**Frontend**: pages, forms, tables, API
#### Authentication & Authorization
### Authentication & Authorization
- JWT / Session-based authentication
- JWT / session-based authentication
- Role & permission system
- 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 |
| ----------------------- | ---------------------------------------------- |
| `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 |
### Documentation
#### Build
| Command | Description |
| ----------------------- | ----------------------------- |
| `npm run build:backend` | Build the backend application |
| `npm run build:admin` | Build the admin application |
#### Versions
| 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.
| Topic | Where |
| ------------------------ | ---------------------------------------------------- |
| Documentation home | [docs/Readme.md](./docs/Readme.md) |
| Public website | [apps/web/Readme.md](./apps/web/Readme.md) |
| Architecture | [docs/architecture/](./docs/architecture/Readme.md) |
| Core modules | [docs/core/](./docs/Readme.md#core-modules) |
| Shared packages | [docs/packages/Readme.md](./docs/packages/Readme.md) |
| Install and setup issues | [troubleshooting.md](./troubleshooting.md) |
+4 -2
View File
@@ -1,2 +1,4 @@
VITE_API_URL=http://0.0.0.0:3333
VITE_TINYMCE_API_KEY=
VITE_API_URL=http://localhost:3333
VITE_TINYMCE_API_KEY=
VITE_WEB_URL=http://localhost:3000
VITE_LAYOUT=
+5 -7
View File
@@ -1,16 +1,14 @@
FROM node:22-alpine3.20
FROM node:24-alpine3.22
WORKDIR /app
# Install workspace deps
# Copy all workspaces before installation so npm resolves workspace dependencies.
COPY package.json package-lock.json ./
RUN npm ci
# Copy project
COPY apps/admin ./apps/admin
COPY apps ./apps
COPY packages ./packages
RUN npm ci --force
WORKDIR /app/apps/admin
ENV CHOKIDAR_USEPOLLING=true
-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
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@crux-kit/admin-app",
"private": true,
"version": "1.0.1",
"version": "1.3.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,6 +12,7 @@
"preview": "vite preview"
},
"dependencies": {
"@crux-kit/editor-tiptap": "*",
"@crux-kit/hooks": "*",
"@crux-kit/intl-date": "*",
"@crux-kit/intl-name": "*",
@@ -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 LAYOUT = import.meta.env['VITE_LAYOUT'];
export const IS_DEV_MODE = import.meta.env.MODE === 'development';
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 = {
layout: '',
container: 'fixed',
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
}
}
layouts
};
+8 -23
View File
@@ -33,29 +33,14 @@ export enum LayoutNames {
DARK_SIDEBAR = 'dark-sidebar-layout', // demo10
}
export interface LayoutOptions {
sidebarCollapse: boolean;
sidebarTheme: 'light' | 'dark';
headerSticky: boolean;
headerStickyOffset: number;
}
export interface Settings {
container: 'fixed' | 'fluid';
layout: string;
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;
};
};
layouts: Record<LayoutNames, LayoutOptions>;
}
+4
View File
@@ -3,6 +3,10 @@
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/typography";
/* @crux-kit/editor-tiptap — Tailwind class scanning + editor content styles */
@source '../../../../../packages/design-system/editor-tiptap/src';
@import '@crux-kit/editor-tiptap/styles.css';
/* ReUI Configs */
@import './config.reui.css';
+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_ACCOUNT": "حسابي",
"USER.MENU.LANGUAGE": "لغة",
"USER.MENU.DARK_MODE": "الوضع المظلم",
"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_FILTERS": "Clear filters",
"ACTION.EDIT": "Edit",
"STATE.CREATING": "Creating",
"STATE.GENERATING": "Generating",
"STATE.SAVING": "Saving",
"STATE.REMOVING": "Removing",
"STATE.LOADING": "Loading",
"STATE.UPDATING": "Updating",
"ERROR": "Error",
"PERMISSION": "Permission",
"PERMISSION.PERMISSIONS": "Permissions",
"PERMISSION.PERMISSION_NAME": "Permission name",
"PERMISSION.SLUG": "Slug",
"PERMISSION.EDIT_PERMISSION": "Edit permission",
"ROLE": "Role",
"ROLE.ROLES": "Roles",
"ROLE.ROLE_NAME": "Role name",
"ROLE.SLUG": "Slug",
"ROLE.EDIT_ROLE": "Edit role",
"USER": "User",
"USER.EDIT_USER": "Edit user",
"USER.STATUS.ACTIVE": "Active",
@@ -51,7 +46,6 @@
"USER.MENU.LANGUAGE": "Language",
"USER.MENU.DARK_MODE": "Dark Mode",
"USER.MENU.LOGOUT": "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} more",
"ADMIN.ADMIN_MANAGEMENT": "Admin Management",
"ADMIN.ADMINS": "Admins",
"DASHBOARD": "Dashboard",
"FORM.ERROR.REQUIRED": "Required",
"FORM.ERROR.INVALID_EMAIL_ADDRESS": "Invalid email address",
"FORM.ERROR.PASSWORDS_MUST_MATCH": "Passwords must match",
"FORM.ERROR.MIN_PASSWORD_LENGTH_DESC": "Min. password length - 8 chars.",
"FORM.ERROR.SOMETHING_WENT_WRONG": "Something went wrong",
"TABLE.COMMON.COLUMNS": "Columns",
"TABLE.COMMON.NO_DATA_AVAILABLE": "No data available",
"TABLE.COMMON.ROWS_PER_PAGE": "Rows per page",
"TABLE.COMMON.GO_TO_PREVIOUS_PAGE": "Go to previous page",
"TABLE.COMMON.GO_TO_NEXT_PAGE": "Go to next page",
"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_ALT": "Avatar",
"PROFILE.AVATAR_SETTINGS": "Avatar Settings",
"PROFILE.AVATAR_CROP_ME": "Crop me",
"PROFILE.EDIT_PROFILE_IMAGE": "Edit profile image",
"MODULE.COMMON.REMOVE_ITEM_QUESTION": "Are you sure you want to remove {target}?",
"MODULE.COMMON.ARE_YOU_SURE?": "Are you sure?",
"MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC": "This action cannot be undone.",
"MODULE.COMMON.CREATE_NEW_ENTITY": "Create new {entity}",
"MODULE.COMMON.CREATE_NEW_USER": "Create new user",
"MESSAGE.PLEASE_WAIT": "Please wait",
"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",
"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.OPTION.FROM_CLOUD_STORAGE": "From cloud storage",
-15
View File
@@ -20,28 +20,23 @@
"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": "Активний",
@@ -51,7 +46,6 @@
"USER.MENU.LANGUAGE": "Мова",
"USER.MENU.DARK_MODE": "Темний режим",
"USER.MENU.LOGOUT": "Вийти",
"SUCCESS": "Успіх",
"ACTIONS": "Дії",
"FILTER": "Фільтр",
@@ -73,42 +67,33 @@
"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 x 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": "З хмарного сховища",
+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="flex items-center gap-1.25 text-xs lg:text-sm font-medium mb-2.5 lg:mb-0">
{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="size-3.5 text-muted-foreground" 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="grow content 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 md:flex-row justify-center md:justify-between items-center gap-3 py-5">
<div className="flex order-2 md:order-1 gap-2 font-normal text-sm">
<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="flex order-1 md:order-2 gap-4 font-normal text-sm text-muted-foreground">
<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 { useLocation } from 'react-router';
import { Link } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { useIsMobile, useScrollPosition } from '@crux-kit/hooks';
import { cn } from '@crux-kit/utils';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { Container } from '#shared/widgets/common/Container';
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
import { MegaMenu } from './MegaMenu';
import { MegaMenuMobile } from './MegaMenuMobile';
import { AppLogo, UserAvatarMenu } from '../../shared';
import { SidebarMenu } from './SidebarMenu';
export function Header() {
const [isSidebarSheetOpen, setIsSidebarSheetOpen] = useState(false);
const [isMegaMenuSheetOpen, setIsMegaMenuSheetOpen] = useState(false);
const { pathname } = useLocation();
const mobileMode = useIsMobile();
const isMobile = useIsMobile();
const scrollPosition = useScrollPosition();
const headerSticky: boolean = scrollPosition > 0;
// Close sheet when route changes
useEffect(() => {
setIsSidebarSheetOpen(false);
setIsMegaMenuSheetOpen(false);
}, [pathname]);
return (
<header
className={cn(
'header fixed top-0 z-10 start-0 flex items-stretch shrink-0 border-b border-transparent bg-background end-0 pe-(--removed-body-scroll-bar-size,0px)',
headerSticky && 'border-b border-border'
'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'
)}
>
<Container className="flex justify-between items-stretch lg:gap-4">
{/* HeaderLogo */}
<div className="flex lg:hidden items-center gap-2.5">
<Link to="/" className="shrink-0">
<img
src={toAbsoluteUrl('/media/app/mini-logo.svg')}
className="h-6.25 w-full"
alt="mini-logo"
/>
</Link>
<div className="flex items-center">
{mobileMode && (
<Sheet open={isSidebarSheetOpen} onOpenChange={setIsSidebarSheetOpen}>
<SheetTrigger asChild>
<Button variant="ghost" mode="icon">
<Menu className="text-muted-foreground/70" />
</Button>
</SheetTrigger>
<SheetContent className="p-0 gap-0 w-68.75" side="left" close={false}>
<SheetHeader className="p-0 space-y-0" />
<SheetBody className="p-0 overflow-y-auto">
<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="p-0 gap-0 w-68.75" side="left" close={false}>
<SheetHeader className="p-0 space-y-0" />
<SheetBody className="p-0 overflow-y-auto">
<MegaMenuMobile />
</SheetBody>
</SheetContent>
</Sheet>
)}
</div>
<Container className="flex items-stretch justify-between lg:gap-4">
<div className="flex items-center gap-2.5 lg:hidden">
<AppLogo className="h-6.25" />
{isMobile && (
<Sheet open={isSidebarSheetOpen} onOpenChange={setIsSidebarSheetOpen}>
<SheetTrigger asChild>
<Button variant="ghost" mode="icon">
<Menu 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">
<SidebarMenu />
</SheetBody>
</SheetContent>
</Sheet>
)}
</div>
{/* Main Content (MegaMenu) */}
{!mobileMode && <MegaMenu />}
{/* HeaderTopbar */}
<div className="flex items-center gap-3">
<UserDropdownMenu
trigger={
<img
className="size-9 rounded-full border-2 border-green-500 shrink-0 cursor-pointer"
src={toAbsoluteUrl('/media/avatars/300-2.png')}
alt="User Avatar"
/>
}
/>
<div className="flex grow items-center justify-end gap-3">
<UserAvatarMenu />
</div>
</Container>
</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 items-center justify-between grow 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 grow shrink-0 py-5 px-5">
<AccordionMenu
selectedValue={pathname}
matchPath={matchPath}
type="single"
collapsible
classNames={classNames}
>
{/* {buildMenu(MENU_MEGA_MOBILE)} */}
</AccordionMenu>
</div>
);
}
@@ -1,21 +1,17 @@
import { useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#providers/SettingsProvider';
import { useSettings } from '#app/providers/SettingsProvider';
import { SidebarHeader } from './SidebarHeader';
import { SidebarMenu } from './SidebarMenu';
export function Sidebar() {
const { settings } = useSettings();
const { pathname } = useLocation();
return (
<div
className={cn(
'sidebar bg-background lg:border-e lg:border-border lg:fixed lg:top-0 lg:bottom-0 lg:z-20 lg:flex flex-col items-stretch shrink-0',
(settings.layouts[LayoutNames.DEFAULT].sidebarTheme === 'dark' ||
pathname.includes('dark-sidebar')) &&
'dark'
'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' && 'dark'
)}
>
<SidebarHeader />
@@ -1,59 +1,34 @@
import { ChevronFirst } from 'lucide-react';
import { Link } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { useSettings } from '#app/providers/SettingsProvider';
import { Button } from '#shared/ui/Button';
import { useSettings } from '#providers/SettingsProvider';
import { AppLogo } from '../../shared';
export function SidebarHeader() {
const { settings, storeOption } = useSettings();
const collapsed = settings.layouts[LayoutNames.DEFAULT].sidebarCollapse;
const handleToggleClick = () => {
storeOption(
`layouts.${LayoutNames.DEFAULT}.sidebarCollapse`,
!settings.layouts[LayoutNames.DEFAULT].sidebarCollapse
);
storeOption(`layouts.${LayoutNames.DEFAULT}.sidebarCollapse`, !collapsed);
};
return (
<div className="sidebar-header hidden lg:flex items-center relative justify-between px-3 lg:px-6 shrink-0">
<Link to="/">
<div className="dark:hidden">
{/* <img
src={toAbsoluteUrl('/media/app/default-logo.svg')}
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>
<div className="sidebar-header relative hidden shrink-0 items-center px-5 lg:flex">
<AppLogo className="size-9 max-w-none" />
<span className="default-logo ml-2 text-2xl font-bold tracking-widest whitespace-nowrap">
C.R.U.X
</span>
<Button
onClick={handleToggleClick}
size="sm"
mode="icon"
variant="outline"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className={cn(
'size-7 absolute start-full top-2/4 rtl:translate-x-2/4 -translate-x-2/4 -translate-y-2/4',
settings.layouts[LayoutNames.DEFAULT].sidebarCollapse
? 'ltr:rotate-180'
: 'rtl:rotate-180'
'absolute inset-s-full top-2/4 size-7 -translate-x-2/4 -translate-y-2/4 rtl:translate-x-2/4',
collapsed ? 'ltr:rotate-180' : 'rtl:rotate-180'
)}
>
<ChevronFirst className="size-4!" />
@@ -1,180 +1,22 @@
'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 { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
import { Badge } from '#shared/ui/Badge';
import { SidebarAccordionMenu } from '../../shared';
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 items-center justify-start grow 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 (
<div className="kt-scrollable-y-hover flex grow shrink-0 py-5 px-5 lg:max-h-[calc(100vh-5.5rem)]">
<AccordionMenu
selectedValue={pathname}
matchPath={matchPath}
type="single"
collapsible
classNames={classNames}
>
{buildMenu(menuConfig)}
</AccordionMenu>
<div className="kt-scrollable-y-hover flex shrink-0 grow px-5 py-5 lg:max-h-[calc(100vh-5.5rem)]">
<SidebarAccordionMenu
classNames={{
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: ''
}}
/>
</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="flex [.header_&]:below-lg:hidden items-center gap-1.25 text-xs lg:text-sm font-medium mb-2.5 lg:mb-0">
<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="size-3.5 muted-foreground" />}
</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-xl font-medium leading-none text-mono">
{title || item?.title || 'Untitled'}
</h1>
{description && (
<div className="flex items-center gap-2 text-sm font-normal text-muted-foreground">
{description}
</div>
)}
</div>
);
}
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
@@ -5,43 +5,41 @@ import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#providers/SettingsProvider';
import { Footer } from './components/Footer';
import { useSettings } from '#app/providers/SettingsProvider';
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar';
export function DefaultLayout() {
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(() => {
const bodyClass = document.body.classList;
if (settings.layouts[LayoutNames.DEFAULT].sidebarCollapse) {
if (sidebarCollapse) {
bodyClass.add('sidebar-collapse');
} else {
bodyClass.remove('sidebar-collapse');
}
}, [settings]); // Runs only on settings update
useEffect(() => {
// Set current layout
setOption('layout', LayoutNames.DEFAULT);
}, [setOption]);
}, [sidebarCollapse]);
useEffect(() => {
const bodyClass = document.body.classList;
// Add a class to the body element
bodyClass.add(LayoutNames.DEFAULT);
bodyClass.add('sidebar-fixed');
bodyClass.add('header-fixed');
const timer = setTimeout(() => {
bodyClass.add('layout-initialized');
}, 1000); // 1000 milliseconds
}, 1000);
// Remove the class when the component is unmounted
return () => {
bodyClass.remove(LayoutNames.DEFAULT);
bodyClass.remove('sidebar-fixed');
@@ -50,7 +48,7 @@ export function DefaultLayout() {
bodyClass.remove('layout-initialized');
clearTimeout(timer);
};
}, []); // Runs only once on mount
}, []);
return (
<>
@@ -60,10 +58,15 @@ export function DefaultLayout() {
<Header />
<main className="grow pt-5" role="content">
{showToolbar && (
<Toolbar>
<ToolbarHeading />
</Toolbar>
)}
<Outlet />
</main>
<Footer />
{showFooter && <AppFooter />}
</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 md:flex-row justify-center md:justify-between items-center gap-3 py-5">
<div className="flex order-2 md:order-1 gap-2 font-normal text-sm">
<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="flex order-1 md:order-2 gap-4 font-normal text-sm text-muted-foreground">
<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 { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
import { Container } from '#shared/widgets/common/Container';
import { useSettings } from '#providers/SettingsProvider';
import { UserAvatarMenu } from '../../shared';
import { HeaderLogo } from './HeaderLogo';
import { HeaderTopbar } from './HeaderTopbar';
export function Header() {
const { settings } = useSettings();
const scrollPosition = useScrollPosition();
const [headerStickyOn, setHeaderStickyOn] = useState(false);
useEffect(() => {
const isSticky =
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
setHeaderStickyOn(isSticky);
}, [scrollPosition, settings]);
const { headerSticky, headerStickyOffset } = settings.layouts[LayoutNames.HORIZONTAL_MENU];
useEffect(() => {
if (headerStickyOn === true) {
setHeaderStickyOn(scrollPosition > headerStickyOffset);
}, [scrollPosition, headerStickyOffset]);
useEffect(() => {
if (headerStickyOn) {
document.body.setAttribute('data-sticky-header', 'on');
} else {
document.body.removeAttribute('data-sticky-header');
}
return () => document.body.removeAttribute('data-sticky-header');
}, [headerStickyOn]);
return (
<header
className={cn(
'flex items-center transition-[height] shrink-0 h-(--header-height) in-data-[sticky-header=on]:pe-(--removed-body-scroll-bar-size,0px)',
settings.layouts[LayoutNames.HORIZONTAL_MENU].headerSticky &&
'flex h-(--header-height) shrink-0 items-center transition-[height] in-data-[sticky-header=on]:pe-(--removed-body-scroll-bar-size,0px)',
headerSticky &&
headerStickyOn &&
'transition-[height] fixed z-10 top-0 start-0 end-0 shadow-xs backdrop-blur-md bg-background/70'
'bg-background/70 fixed start-0 end-0 top-0 z-10 shadow-xs backdrop-blur-md transition-[height]'
)}
>
<Container className="flex justify-between items-center lg:gap-4">
<Container className="flex items-center justify-between lg:gap-4">
<HeaderLogo />
<HeaderTopbar />
<UserAvatarMenu />
</Container>
</header>
);
@@ -1,71 +1,41 @@
// import { useEffect, useState } from 'react';
// import { ChevronDown } from 'lucide-react';
import { Link } from 'react-router-dom';
// import { MENU_ROOT } from '#app/menu';
// import {
// DropdownMenu,
// DropdownMenuContent,
// DropdownMenuItem,
// DropdownMenuTrigger,
// } from '#shared/ui/dropdown_menu';
import { toAbsoluteUrl } from '#shared/lib/helpers';
// import { cn } from '@crux-kit/utils';
import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useIsMobile } from '@crux-kit/hooks';
import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { AppLogo, AppWordmark, SidebarAccordionMenu } from '../../shared';
export function HeaderLogo() {
// const { pathname } = useLocation();
// const [selectedMenuItem, setSelectedMenuItem] = useState(MENU_ROOT[1]);
const { pathname } = useLocation();
const isMobile = useIsMobile();
const [isSheetOpen, setIsSheetOpen] = useState(false);
// useEffect(() => {
// MENU_ROOT.forEach((item) => {
// if (item.rootPath && pathname.includes(item.rootPath)) {
// setSelectedMenuItem(item);
// }
// });
// }, [pathname]);
useEffect(() => {
setIsSheetOpen(false);
}, [pathname]);
return (
<div className="flex items-center gap-2 lg:gap-5 2xl:-ms-15">
{/* Logo Section */}
<Link to="/" className="shrink-0">
<img
src={toAbsoluteUrl('/media/app/mini-logo-circle.svg')}
className="dark:hidden min-h-10.5"
alt="logo"
/>
<img
src={toAbsoluteUrl('/media/app/mini-logo-circle-dark.svg')}
className="hidden dark:inline-block min-h-10.5"
alt="logo"
/>
</Link>
<AppLogo className="size-10.5" />
{/* Menu Section */}
<div className="flex items-center gap-3">
<h3 className="text-accent-foreground text-base hidden md:block">Metronic Team</h3>
<span className="text-sm text-muted-foreground font-medium hidden md:inline">/</span>
<AppWordmark className="text-accent-foreground hidden text-base md:block" />
{/* <DropdownMenu>
<DropdownMenuTrigger className="cursor-pointer text-mono font-medium flex items-center gap-2">
{selectedMenuItem.title}
<ChevronDown className="size-4 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>
{isMobile && (
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild>
<Button variant="ghost" mode="icon">
<Menu />
</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 py-5">
<SidebarAccordionMenu />
</SheetBody>
</SheetContent>
</Sheet>
)}
</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="cursor-pointer size-9 rounded-full justify-center border border-gray-500 shrink-0"
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 { NavbarLinks } from './NavbarLinks';
import { NavbarMenu } from './NavbarMenu';
import { NavMenubar } from '../../shared';
export function Navbar() {
const isMobile = useIsMobile();
if (isMobile) {
return null;
}
return (
<div className="border-b border-border pb-5 lg:pb-0 mb-5 lg:mb-10">
<Container className="flex flex-wrap justify-between items-center gap-2">
<NavbarMenu />
<NavbarLinks />
<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">
<NavMenubar />
</Container>
</div>
);
@@ -1,15 +0,0 @@
export function NavbarLinks() {
return (
<div className="flex items-center text-sm gap-5 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(
'flex items-center gap-1.5 px-3 py-3.5 text-sm text-secondary-foreground 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]:bg-transparent data-[state=open]:text-mono',
'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(
'flex items-center py-3.5 text-sm text-secondary-foreground px-3 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 items-stretch border-none bg-transparent p-0 h-auto">
{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 items-center justify-between flex-wrap 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="flex [.header_&]:below-lg:hidden items-center gap-1.25 text-xs lg:text-sm font-medium mb-2.5 lg:mb-0">
<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="size-3.5 muted-foreground" />}
</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 items-center flex-wrap gap-1 lg:gap-5">
<h1 className="font-medium text-lg text-mono">{title || item?.title}</h1>
<ToolbarBreadcrumbs />
</div>
);
};
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
@@ -1,42 +1,23 @@
// DEMO 2
// https://keenthemes.com/metronic/tailwind/react/demo2
import { addDays, format } from 'date-fns';
import { CalendarDays } from 'lucide-react';
import { useEffect, useState } from 'react';
import { DateRange } from 'react-day-picker';
import { Outlet, useLocation } from 'react-router-dom';
import { Outlet } from 'react-router-dom';
import { useBodyClass } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types';
import { Button } from '#shared/ui/Button';
import { Calendar } from '#shared/ui/Calendar';
import { Popover, PopoverContent, PopoverTrigger } from '#shared/ui/Popover';
import { useSettings } from '#providers/SettingsProvider';
import { Footer } from './components/Footer';
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
import { Header } from './components/Header';
import { Navbar } from './components/Navbar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export function HorizontalMenuLayout() {
const { pathname } = useLocation();
const { setOption } = useSettings();
useEffect(() => {
// Set current layout
setOption('layout', LayoutNames.HORIZONTAL_MENU);
}, [setOption]);
useLayoutSettings(LayoutNames.HORIZONTAL_MENU);
const { showToolbar, showFooter } = useLayoutChrome();
useBodyClass(`
[--header-height-default:100px]
[--header-height-default:100px]
data-[sticky-header=on]:[--header-height:60px]
[--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 (
<>
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height-default)">
@@ -45,45 +26,15 @@ export function HorizontalMenuLayout() {
<Navbar />
<main className="grow" role="content">
{!pathname.includes('/public-profile/') && (
{showToolbar && (
<Toolbar>
<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>
)}
<Outlet />
</main>
<Footer />
{showFooter && <AppFooter />}
</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 md:flex-row justify-center md:justify-between items-center gap-3 py-5">
<div className="flex order-2 md:order-1 gap-2 font-normal text-sm">
<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="flex order-1 md:order-2 gap-4 font-normal text-sm text-muted-foreground">
<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 { UserAvatarMenu } from '../../shared';
import { HeaderLogo } from './HeaderLogo';
import { HeaderTopbar } from './HeaderTopbar';
export function Header() {
return (
<header className="flex items-center fixed z-10 top-0 left-0 right-0 shrink-0 h-(--header-height) bg-muted">
<Container className="flex justify-between items-stretch px-5 lg:ps-0 lg:gap-4">
<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">
<HeaderLogo />
<HeaderTopbar />
<UserAvatarMenu />
</Container>
</header>
);
@@ -1,87 +1,39 @@
import { Menu } from 'lucide-react';
import { Link } from 'react-router-dom';
import { toAbsoluteUrl } from '#shared/lib/helpers';
// import { cn } from '@crux-kit/utils';
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
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 { SidebarMenu } from './SidebarMenu';
import { AppLogo, AppWordmark, SidebarAccordionMenu } from '../../shared';
export function HeaderLogo() {
// const { pathname } = useLocation();
// const [selectedMenuItem, setSelectedMenuItem] = useState(MENU_ROOT[1]);
const { pathname } = useLocation();
const [isSheetOpen, setIsSheetOpen] = useState(false);
// useEffect(() => {
// MENU_ROOT.forEach((item) => {
// if (item.rootPath && pathname.includes(item.rootPath)) {
// setSelectedMenuItem(item);
// }
// });
// }, [pathname]);
useEffect(() => {
setIsSheetOpen(false);
}, [pathname]);
return (
<div className="flex items-center gap-2.5">
{/* Logo */}
<div className="flex items-center justify-center lg:w-(--sidebar-width) shrink-0">
<Sheet>
<div className="flex shrink-0 items-center justify-center lg:w-(--sidebar-width)">
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild>
<Button variant="ghost" mode="icon" className="-ms-2 lg:hidden">
<Menu className="size-4!" />
</Button>
</SheetTrigger>
<SheetContent className="p-0 gap-0 w-(--sidebar-width)" side="left" close={false}>
<SheetHeader className="p-0 space-y-0" />
<SheetBody className="p-0 overflow-y-auto">
<SidebarMenu />
<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 py-5">
<SidebarAccordionMenu />
</SheetBody>
</SheetContent>
</Sheet>
<Link to="/" className="mx-1">
<img
src={toAbsoluteUrl('/media/app/mini-logo-primary.svg')}
className="dark:hidden min-h-6"
alt="logo"
/>
<img
src={toAbsoluteUrl('/media/app/mini-logo-primary-dark.svg')}
className="hidden dark:inline-block min-h-6"
alt="logo"
/>
</Link>
<AppLogo className="size-6" linkClassName="mx-1" />
</div>
{/* Menu Section */}
<div className="flex items-center gap-3">
<h3 className="text-accent-foreground text-base hidden md:block">Metronic Team</h3>
<span className="text-sm text-muted-foreground font-medium hidden 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>
<AppWordmark className="text-accent-foreground hidden text-base md:block" />
</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="size-9 rounded-full border-2 border-input shrink-0 cursor-pointer"
src={toAbsoluteUrl('/media/avatars/gray/5.png')}
alt="User Avatar"
/>
}
/>
</div>
);
}
@@ -1,14 +1,20 @@
import { Container } from '#shared/widgets/common/Container';
import { NavbarLinks } from './NavbarLinks';
import { NavbarMenu } from './NavbarMenu';
import { NavMenubar, useActiveMenuSection } from '../../shared';
/** The children of the section selected in the compact sidebar. */
export function Navbar() {
const section = useActiveMenuSection();
const items = section?.children ?? [];
if (items.length === 0) {
return null;
}
return (
<div className="flex items-stretch lg:fixed z-5 top-(--header-height) start-(--sidebar-width) end-5 h-(--navbar-height) mx-5 lg:mx-0 bg-muted">
<div className="rounded-t-xl border border-border bg-background flex items-stretch grow">
<Container className="flex justify-between items-stretch gap-5">
<NavbarMenu />
<NavbarLinks />
<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">
<Container className="flex items-stretch justify-between gap-5">
<NavMenubar items={items} />
</Container>
</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(
'flex items-center gap-1 px-0 py-3.5 text-sm text-secondary-foreground 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]:bg-transparent data-[state=open]:text-mono',
'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(
'flex items-center py-3.5 text-sm text-secondary-foreground px-3 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="space-x-0 flex items-stretch border-none bg-transparent gap-5 p-0 h-auto">
{buildMenu(navbarMenu.children as MenuConfig)}
</Menubar>
</div>
</div>
);
}
@@ -2,9 +2,9 @@ import { SidebarMenu } from './SidebarMenu';
export function Sidebar() {
return (
<div className="fixed w-(--sidebar-width) lg:top-(--header-height) top-0 bottom-0 z-20 lg:flex flex-col items-stretch shrink-0 group py-3 lg:py-0">
<div className="flex grow shrink-0">
<div className="kt-scrollable-y-auto grow gap-2.5 shrink-0 flex items-center flex-col max-h-[calc(100vh-3rem)]">
<div className="group fixed top-0 bottom-0 z-20 w-(--sidebar-width) shrink-0 flex-col items-stretch py-3 lg:top-(--header-height) lg:flex lg:py-0">
<div className="flex shrink-0 grow">
<div className="kt-scrollable-y-auto flex max-h-[calc(100vh-3rem)] shrink-0 grow flex-col items-center gap-2.5">
<SidebarMenu />
</div>
</div>
@@ -1,99 +1,52 @@
import { BarChart3, Bell, CheckSquare, Code, HelpCircle, MessageSquare, Settings, Shield, UserCircle, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { useMenu } from '#shared/hooks/useMenu';
import { Button } from '#shared/ui/Button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip';
import { firstMenuPath, useAppMenu } from '../../shared';
export interface Item {
icon: React.ComponentType<{ className?: string }>;
path: string;
title: string;
newTab?: boolean;
active?: boolean;
}
/**
* The section chooser: one icon per top-level menu entry. Its children are shown by the navbar, so
* this rail never needs to expand.
*/
export function SidebarMenu() {
const items: Item[] = [
{
icon: BarChart3,
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'
}
];
const menuConfig = useAppMenu();
const { pathname } = useLocation();
const { isItemActive } = useMenu(pathname);
return (
<TooltipProvider>
<div className="flex flex-col grow items-center py-3.5 lg:py-0 gap-2.5">
{items.map((item, index) => (
<Tooltip key={index}>
<TooltipTrigger asChild>
<Button
variant="ghost"
shape="circle"
mode="icon"
{...(item.active ? { 'data-state': 'open' } : {})}
className={cn(
'data-[state=open]:bg-background data-[state=open]:border data-[state=open]:border-input data-[state=open]:text-primary',
'hover:bg-background hover:border hover:border-input hover:text-primary'
)}
>
<Link
to={item.path || ''}
{...(item.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
<div className="flex grow flex-col items-center gap-2.5 py-3.5 lg:py-0">
{menuConfig.map((item, index) => {
const path = firstMenuPath(item);
if (!path) {
return null;
}
return (
<Tooltip key={index}>
<TooltipTrigger asChild>
<Button
asChild
variant="ghost"
shape="circle"
mode="icon"
{...(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>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{item.title}</TooltipContent>
</Tooltip>
))}
<Link to={path} aria-label={item.title}>
{item.icon && <item.icon className="size-4.5!" />}
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{item.title}</TooltipContent>
</Tooltip>
);
})}
</div>
</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 items-center justify-between flex-wrap 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="flex items-center gap-1.5 text-xs lg:text-sm font-medium mb-2.5 lg:mb-0">
<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="size-3.5 muted-foreground" />}
</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 items-center flex-wrap gap-1 lg:gap-5">
<h1 className="font-medium text-lg text-mono">{title || item?.title}</h1>
<ToolbarBreadcrumbs />
</div>
);
};
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
@@ -1,66 +1,48 @@
// DEMO 3
// https://keenthemes.com/metronic/tailwind/react/demo3
import { Download } from 'lucide-react';
import { useEffect } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom';
import { Outlet } from 'react-router-dom';
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types';
import { Button } from '#shared/ui/Button';
import { useSettings } from '#providers/SettingsProvider';
import { Footer } from './components/Footer';
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
import { Header } from './components/Header';
import { Navbar } from './components/Navbar';
import { Sidebar } from './components/Sidebar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export function CompactSidebarLayout() {
const { pathname } = useLocation();
const { setOption } = useSettings();
const isMobileMode = useIsMobile();
useLayoutSettings(LayoutNames.COMPACT_SIDEBAR);
const isMobile = useIsMobile();
const { showToolbar, showFooter } = useLayoutChrome();
useBodyClass(`
[--header-height:58px]
[--sidebar-width:58px]
[--navbar-height:56px]
lg:overflow-hidden
[--header-height:58px]
[--sidebar-width:58px]
[--navbar-height:56px]
lg:overflow-hidden
bg-muted!
`);
useEffect(() => {
setOption('layout', LayoutNames.COMPACT_SIDEBAR);
setOption('container', 'fluid');
}, [setOption]);
return (
<>
<div className="flex grow">
<Header />
<div className="flex flex-col lg:flex-row grow pt-(--header-height)">
{!isMobileMode && <Sidebar />}
<div className="flex grow flex-col pt-(--header-height) lg:flex-row">
{!isMobile && <Sidebar />}
<Navbar />
<div className="flex grow rounded-b-xl bg-background border-x border-b border-border lg:mt-(--navbar-height) mx-5 lg:ms-(--sidebar-width) mb-5">
<div className="flex flex-col grow kt-scrollable-y lg:[scrollbar-width:auto] pt-7 lg:**:data-[slot=container]:pe-2">
<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">
<main className="grow" role="content">
{pathname !== '/' && (
{showToolbar && (
<Toolbar>
<ToolbarHeading />
<ToolbarActions>
<Button variant="outline" size="sm" asChild>
<Link to={'/account/home/get-started'}>
<Download />
Export
</Link>
</Button>
</ToolbarActions>
</Toolbar>
)}
<Outlet />
</main>
<Footer />
{showFooter && <AppFooter />}
</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 md:flex-row justify-center md:justify-between items-center gap-3 py-5">
<div className="flex order-2 md:order-1 gap-2 font-normal text-sm">
<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="flex order-1 md:order-2 gap-4 font-normal text-sm text-muted-foreground">
<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 { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { useLocation } from 'react-router-dom';
import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { Container } from '#shared/widgets/common/Container';
import { AppLogo } from '../../shared';
import { SidebarPrimary } from './SidebarPrimary';
import { SidebarSecondary } from './SidebarSecondary';
@@ -12,26 +12,14 @@ export function Header() {
const [isSheetOpen, setIsSheetOpen] = useState(false);
const { pathname } = useLocation();
// Close sheet when route changes
useEffect(() => {
setIsSheetOpen(false);
}, [pathname]);
return (
<header className="flex lg:hidden items-center fixed z-10 top-0 start-0 end-0 shrink-0 bg-(--page-bg) dark:bg-(--page-bg-dark) h-(--header-height)">
<Container className="flex items-center justify-between flex-wrap gap-3">
<Link to="/">
<img
src={toAbsoluteUrl('/media/app/mini-logo-gray.svg')}
className="dark:hidden min-h-7.5"
alt=""
/>
<img
src={toAbsoluteUrl('/media/app/mini-logo-gray-dark.svg')}
className="hidden dark:block min-h-7.5"
alt=""
/>
</Link>
<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">
<AppLogo className="size-7.5" />
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild>
@@ -39,9 +27,9 @@ export function Header() {
<Menu />
</Button>
</SheetTrigger>
<SheetContent className="p-0 gap-0 w-(--sidebar-width)" side="left" close={false}>
<SheetHeader className="p-0 space-y-0" />
<SheetBody className="p-0 flex items-stretch shrink-0 overflow-y-auto">
<SheetContent className="w-(--sidebar-width) gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" />
<SheetBody className="flex shrink-0 items-stretch overflow-y-auto p-0">
<SidebarPrimary />
<SidebarSecondary />
</SheetBody>
@@ -3,7 +3,7 @@ import { SidebarSecondary } from './SidebarSecondary';
export function Sidebar() {
return (
<div className="fixed top-0 bottom-0 z-20 flex items-stretch shrink-0 w-(--sidebar-width) bg-muted">
<div className="bg-muted fixed top-0 bottom-0 z-20 flex w-(--sidebar-width) shrink-0 items-stretch">
<SidebarPrimary />
<SidebarSecondary />
</div>
@@ -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 items-center justify-between grow 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 { useViewport } from '@crux-kit/hooks';
import { cn, getHeight } from '@crux-kit/utils';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { cn } from '@crux-kit/utils';
import { useMenu } from '#shared/hooks/useMenu';
import { Button } from '#shared/ui/Button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip';
// import { AppsDropdownMenu } from '#shared/partials/topbar/apps-dropdown-menu';
// 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: '' }
];
import { AppLogo, firstMenuPath, useAppMenu, UserAvatarMenu } from '../../shared';
export function SidebarPrimary() {
const headerRef = useRef<HTMLDivElement>(null);
const footerRef = useRef<HTMLDivElement>(null);
const [scrollableHeight, setScrollableHeight] = useState<number>(0);
const [viewportHeight] = useViewport();
const scrollableOffset = 80;
const menuConfig = useAppMenu();
const { pathname } = useLocation();
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]);
const { isItemActive } = useMenu(pathname);
return (
<TooltipProvider>
<div className="flex flex-col items-stretch shrink-0 gap-5 py-5 w-17.5 border-e border-input">
<div ref={headerRef} className="hidden lg:flex items-center justify-center shrink-0">
<Link to="/">
<img
src={toAbsoluteUrl('/media/app/mini-logo-gray.svg')}
className="dark:hidden min-h-7.5"
alt=""
/>
<img
src={toAbsoluteUrl('/media/app/mini-logo-gray-dark.svg')}
className="hidden dark:block min-h-7.5"
alt=""
/>
</Link>
<div className="border-input flex w-17.5 shrink-0 flex-col items-stretch gap-5 border-e py-5">
<div className="hidden shrink-0 items-center justify-center lg:flex">
<AppLogo className="size-7.5" />
</div>
<div className="flex grow shrink-0">
<div
className="kt-scrollable-y-hover grow gap-2.5 shrink-0 flex ps-4 flex-col"
style={{ height: `${scrollableHeight}px` }}
>
{menuItems.map((item, index) => (
<div className="kt-scrollable-y-hover flex shrink-0 grow flex-col gap-2.5 ps-4">
{menuConfig.map((item, index) => {
const path = firstMenuPath(item);
if (!path) {
return null;
}
return (
<Tooltip key={index}>
<TooltipTrigger asChild>
<Button
asChild
variant="ghost"
mode="icon"
{...(item === selectedMenuItem ? { 'data-state': 'open' } : {})}
{...(isItemActive(item) ? { 'data-state': 'open' } : {})}
className={cn(
'shrink-0 rounded-md size-9',
'data-[state=open]:bg-background data-[state=open]:border data-[state=open]:border-input data-[state=open]:text-primary',
'hover:bg-background hover:border hover:border-input hover:text-primary'
'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',
'hover:bg-background hover:border-input hover:text-primary hover:border'
)}
>
<Link to={item.path}>
<item.icon className="size-4.5!" />
<Link to={path} aria-label={item.title}>
{item.icon && <item.icon className="size-4.5!" />}
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{item.tooltip}</TooltipContent>
<TooltipContent side="right">{item.title}</TooltipContent>
</Tooltip>
))}
</div>
);
})}
</div>
<div ref={footerRef} className="flex flex-col gap-4 items-center shrink-0">
{/* <ChatSheet
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="size-9 rounded-full border border-border shrink-0 cursor-pointer"
src={toAbsoluteUrl('/media/avatars/300-2.png')}
alt="User Avatar"
/>
}
/>
<div className="flex shrink-0 flex-col items-center gap-4">
<UserAvatarMenu />
</div>
</div>
</TooltipProvider>
@@ -1,13 +1,30 @@
import { useLocation } from 'react-router';
import { SidebarMenuDashboard } from './SidebarMenuDashboard';
import { SidebarMenuDefault } from './SidebarMenuDefault';
import { SidebarAccordionMenu, useActiveMenuSection } from '../../shared';
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() {
const { pathname } = useLocation();
const section = useActiveMenuSection();
const items = section?.children;
return (
<div className="grow shrink-0 ps-3.5 kt-scrollable-y-hover max-h-[calc(100vh-2rem)] pe-1 my-5">
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />}
<div className="kt-scrollable-y-hover my-5 max-h-[calc(100vh-2rem)] shrink-0 grow ps-3.5 pe-1">
<SidebarAccordionMenu classNames={CLASS_NAMES} items={items} />
</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 items-center justify-between flex-wrap 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 items-center flex-wrap gap-1 lg:gap-5">
<h1 className="font-medium text-lg text-mono">{title || item?.title}</h1>
<ToolbarBreadcrumbs />
</div>
);
};
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
@@ -1,90 +1,46 @@
// DEMO 4
// https://keenthemes.com/metronic/tailwind/react/demo4
import { Download } from 'lucide-react';
import { useEffect } from 'react';
import { Link, Outlet } from 'react-router-dom';
import { Outlet } from 'react-router-dom';
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types';
import { Button } from '#shared/ui/Button';
import { useSettings } from '#providers/SettingsProvider';
// import { SearchDialog } from '#shared/partials/dialogs/search/search-dialog';
// import { NotificationsSheet } from '#shared/partials/topbar/notifications-sheet';
import { Footer } from './components/Footer';
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export function TwoColumnSidebarLayout() {
const { setOption } = useSettings();
const isMobileMode = useIsMobile();
useLayoutSettings(LayoutNames.TWO_COLUMN_SIDEBAR);
const isMobile = useIsMobile();
const { showToolbar, showFooter } = useLayoutChrome();
// Using the custom hook to set multiple CSS variables and class properties
useBodyClass(`
[--header-height:60px]
[--sidebar-width:290px]
lg:overflow-hidden
[--header-height:60px]
[--sidebar-width:290px]
lg:overflow-hidden
bg-muted!
`);
useEffect(() => {
setOption('layout', LayoutNames.TWO_COLUMN_SIDEBAR);
}, [setOption]);
return (
<>
<div className="flex grow">
{isMobileMode && <Header />}
{isMobile && <Header />}
<div className="flex flex-col lg:flex-row grow pt-(--header-height) lg:pt-0">
{!isMobileMode && <Sidebar />}
<div className="flex grow flex-col pt-(--header-height) lg:flex-row lg:pt-0">
{!isMobile && <Sidebar />}
<div className="flex grow rounded-xl bg-background border border-input lg:ms-(--sidebar-width) mt-0 lg:mt-5 m-5">
<div className="flex flex-col grow kt-scrollable-y-auto lg:[--kt-scrollbar-width:auto] pt-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]">
<main className="grow" role="content">
<Toolbar>
<ToolbarHeading />
<ToolbarActions>
{/* <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>
{showToolbar && (
<Toolbar>
<ToolbarHeading />
</Toolbar>
)}
<Outlet />
</main>
<Footer />
{showFooter && <AppFooter />}
</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 md:flex-row justify-center md:justify-between items-center gap-3 py-5">
<div className="flex order-2 md:order-1 gap-2 font-normal text-sm">
<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="flex order-1 md:order-2 gap-4 font-normal text-sm text-muted-foreground">
<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>
);
}
@@ -2,44 +2,45 @@ import { useEffect, useState } from 'react';
import { useScrollPosition } from '@crux-kit/hooks';
import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
import { Container } from '#shared/widgets/common/Container';
import { useSettings } from '#providers/SettingsProvider';
import { UserAvatarMenu } from '../../shared';
import { HeaderLogo } from './HeaderLogo';
import { HeaderTopbar } from './HeaderTopbar';
const Header = () => {
export function Header() {
const { settings } = useSettings();
const scrollPosition = useScrollPosition();
const [headerStickyOn, setHeaderStickyOn] = useState(false);
useEffect(() => {
const isSticky =
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
setHeaderStickyOn(isSticky);
}, [scrollPosition, settings]);
const { headerSticky, headerStickyOffset } = settings.layouts[LayoutNames.DUAL_ROW_HEADER];
useEffect(() => {
if (headerStickyOn === true) {
setHeaderStickyOn(scrollPosition > headerStickyOffset);
}, [scrollPosition, headerStickyOffset]);
useEffect(() => {
if (headerStickyOn) {
document.body.setAttribute('data-sticky-header', 'on');
} else {
document.body.removeAttribute('data-sticky-header');
}
return () => document.body.removeAttribute('data-sticky-header');
}, [headerStickyOn]);
return (
<header
className={cn(
'flex items-center transition-[height] shrink-0 bg-(--header-bg) dark:bg-(--header-bg-dark) h-(--header-height)',
headerStickyOn &&
'transition-[height] fixed z-10 top-0 start-0 end-0 shadow-xs backdrop-blur-md bg-white/70'
'flex h-(--header-height) shrink-0 items-center transition-[height]',
headerSticky &&
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 justify-between items-center lg:gap-4">
<Container className="flex items-center justify-between gap-4">
<HeaderLogo />
<HeaderTopbar />
<UserAvatarMenu />
</Container>
</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 { Link, useLocation } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { SidebarMenuDashboard } from './SidebarMenuDashboard';
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>;
import { AppLogo, AppWordmark, SidebarAccordionMenu } from '../../shared';
export function HeaderLogo() {
const { pathname } = useLocation();
const [isSheetOpen, setIsSheetOpen] = useState(false);
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(() => {
setIsSheetOpen(false);
}, [pathname]);
return (
<div className="flex items-center gap-1.5 lg:gap-5">
<Link to="/">
<img
src={toAbsoluteUrl('/media/app/mini-logo-circle.svg')}
className="dark:hidden min-h-8.5"
alt="logo"
/>
<img
src={toAbsoluteUrl('/media/app/mini-logo-circle-dark.svg')}
className="hidden dark:inline-block min-h-8.5"
alt="logo"
/>
</Link>
<AppLogo className="size-8.5" />
<AppWordmark className="text-accent-foreground hidden text-base md:block" />
{isMobile && (
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
@@ -111,91 +28,14 @@ export function HeaderLogo() {
<Menu />
</Button>
</SheetTrigger>
<SheetContent className="p-0 gap-0 w-62.5" side="left" close={false}>
<SheetHeader className="p-0 space-y-0" />
<SheetBody className="p-3 overflow-y-auto">
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />}
<SheetContent className="w-62.5 gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" />
<SheetBody className="overflow-y-auto py-5">
<SidebarAccordionMenu />
</SheetBody>
</SheetContent>
</Sheet>
)}
{!isMobile && (
<div className="lg:flex items-stretch gap-3">
{/* Teams Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className="cursor-pointer text-secondary-foreground text-sm font-medium flex items-center gap-2">
{selectedTeam.title}
<ChevronDown className="size-3.5 text-muted-foreground" />
</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-sm text-muted-foreground font-medium px-2.5 md:inline">/</span>
{/* Items Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className="cursor-pointer text-secondary-foreground text-sm font-medium flex items-center gap-2">
{selectedItem.title}
<ChevronDown className="size-3.5 text-muted-foreground" />
</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-sm text-muted-foreground font-medium px-2.5">/</span>
{/* Staging Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className="cursor-pointer text-secondary-foreground text-sm font-medium flex items-center gap-2">
{selectedStaging.title}
<ChevronDown className="size-3.5 text-muted-foreground" />
</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>
);
}
@@ -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="cursor-pointer size-9 rounded-full border-2 border-mono/25 shrink-0"
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 { NavbarMenu } from './NavbarMenu';
import { NavMenubar } from '../../shared';
/** The top-level sections, on the header's second row. */
const Navbar = () => {
const isMobile = useIsMobile();
if (isMobile) {
return null;
}
return (
<div className="bg-bg-background border-y border-border mb-5 lg:mb-8">
<Container className="flex flex-wrap justify-between items-center gap-2">
<NavbarMenu />
<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">
<NavMenubar />
</Container>
</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 items-stretch h-12 gap-5 lg:gap-7.5">
{items.map((item, index) => (
<Link
key={index}
to={item.path}
className={cn(
'flex items-center text-nowrap font-medium text-sm text-secondary-foreground',
item.path === selectedItem.path && 'border-b border-mono text-mono'
)}
>
{item.title}
</Link>
))}
</div>
</div>
</div>
);
}
@@ -1,13 +1,30 @@
import { useLocation } from 'react-router-dom';
import { SidebarMenuDashboard } from './SidebarMenuDashboard';
import { SidebarMenuDefault } from './SidebarMenuDefault';
import { SidebarAccordionMenu, useActiveMenuSection } from '../../shared';
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() {
const { pathname } = useLocation();
const section = useActiveMenuSection();
const items = section?.children;
if (!items || items.length === 0) {
return null;
}
return (
<div className="flex items-stretch shrink-0 px-2 w-(--sidebar-width) bg-background">
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />}
<div className="bg-background flex w-(--sidebar-width) shrink-0 items-stretch px-2">
<SidebarAccordionMenu classNames={CLASS_NAMES} items={items} />
</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 items-center justify-between grow 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 items-center justify-between flex-wrap 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 md:flex-row md:items-center flex-wrap gap-1 lg:gap-5">
<h1 className="font-medium text-lg text-mono">{title || item?.title}</h1>
<ToolbarBreadcrumbs />
</div>
);
};
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
@@ -1,44 +1,24 @@
// DEMO 5
// https://keenthemes.com/metronic/tailwind/react/demo5
import { addDays, format } from 'date-fns';
import { CalendarDays } from 'lucide-react';
import { useEffect, useState } from 'react';
import { DateRange } from 'react-day-picker';
import { Outlet, useLocation } from 'react-router';
import { Outlet } from 'react-router';
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types';
import { Button } from '#shared/ui/Button';
import { Calendar } from '#shared/ui/Calendar';
import { Popover, PopoverContent, PopoverTrigger } from '#shared/ui/Popover';
import { useSettings } from '#providers/SettingsProvider';
import { Footer } from './components/Footer';
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
import { Header } from './components/Header';
import { Navbar } from './components/Navbar';
import { Sidebar } from './components/Sidebar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export const DualRowsHeaderLayout = () => {
const isMobile = useIsMobile();
const { pathname } = useLocation();
const { setOption } = useSettings();
useLayoutSettings(LayoutNames.DUAL_ROW_HEADER);
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(`
[--header-height:54px]
[--sidebar-width:200px]
[--sidebar-width:200px]
`);
useEffect(() => {
setOption('layout', LayoutNames.DUAL_ROW_HEADER);
setOption('container', 'fluid');
}, [setOption]);
return (
<>
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height)">
@@ -46,48 +26,19 @@ export const DualRowsHeaderLayout = () => {
<Navbar />
<div className="w-full flex px-0 lg:ps-4">
<div className="flex w-full px-0 lg:ps-4">
{!isMobile && <Sidebar />}
<main className="flex flex-col grow">
{!pathname.includes('/public-profile/') && (
<main className="flex grow flex-col">
{showToolbar && (
<Toolbar>
<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>
)}
<Outlet />
<Footer />
{showFooter && <AppFooter />}
</main>
</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 { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useIsMobile } from '@crux-kit/hooks';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { useLocation } from 'react-router-dom';
import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { Container } from '#shared/widgets/common/Container';
import { AppLogo } from '../../shared';
import { SidebarFooter } from './SidebarFooter';
import { SidebarHeader } from './SidebarHeader';
import { SidebarMenu } from './SidebarMenu';
@@ -13,46 +12,31 @@ import { SidebarMenu } from './SidebarMenu';
const Header = () => {
const { pathname } = useLocation();
const [isSheetOpen, setIsSheetOpen] = useState(false);
const isMobile = useIsMobile();
// Close sheet when route changes
useEffect(() => {
setIsSheetOpen(false);
}, [pathname]);
return (
<header className="flex lg:hidden items-center fixed z-10 top-0 start-0 end-0 shrink-0 bg-muted h-(--header-height)">
<Container className="flex items-center justify-between flex-wrap gap-3">
<Link to="/">
<img
src={toAbsoluteUrl('/media/app/logo-light.svg')}
className="dark:hidden h-8.5"
alt="Logo"
/>
<img
src={toAbsoluteUrl('/media/app/logo-dark.svg')}
className="hidden dark:block h-8.5"
alt="Logo"
/>
</Link>
<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">
<AppLogo />
{isMobile && (
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild>
<Button variant="ghost" mode="icon">
<Menu />
</Button>
</SheetTrigger>
<SheetContent className="p-0 gap-0 w-68.75" side="left" close={false}>
<SheetHeader className="p-0 space-y-0" />
<SheetBody className="p-0 flex flex-col grow">
<SidebarHeader />
<SidebarMenu />
<SidebarFooter />
</SheetBody>
</SheetContent>
</Sheet>
)}
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild>
<Button variant="ghost" mode="icon">
<Menu />
</Button>
</SheetTrigger>
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" />
<SheetBody className="flex grow flex-col p-0">
<SidebarHeader />
<SidebarMenu />
<SidebarFooter />
</SheetBody>
</SheetContent>
</Sheet>
</Container>
</header>
);
@@ -80,7 +80,7 @@ export const RoosterPeek: React.FC = () => {
<img
key={tick}
src={toAbsoluteUrl('/media/app/brutal-aggressive-rooster-head-icon.svg')}
className="size-50 fixed z-1000"
className="fixed z-1000 size-50"
style={buildStyle(side)}
alt="Brutal rooster"
/>
@@ -4,7 +4,7 @@ import { SidebarMenu } from './SidebarMenu';
export function Sidebar() {
return (
<div className="fixed top-0 bottom-0 z-50 lg:flex flex-col shrink-0 w-(--sidebar-width) bg-(--page-bg) dark:bg-(--page-bg-dark)">
<div className="fixed top-0 bottom-0 z-50 w-(--sidebar-width) shrink-0 flex-col bg-(--page-bg) lg:flex dark:bg-(--page-bg-dark)">
<SidebarHeader />
<SidebarMenu />
<SidebarFooter />
@@ -1,25 +1,9 @@
import { IntlName } from '#infrastructure/IntlName';
import { useAuth } from '#core/Auth/providers/AuthProvider';
import { Avatar, AvatarFallback, AvatarImage } from '#shared/ui/Avatar';
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
import { UserAvatarMenu } from '../../shared';
export function SidebarFooter() {
const { user } = useAuth();
return (
<div className="flex flex-center justify-between shrink-0 ps-4 pe-3.5 h-14">
{user && (
<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 className="flex-center flex h-14 shrink-0 justify-between ps-4 pe-3.5">
<UserAvatarMenu />
</div>
);
}
@@ -1,41 +1,29 @@
import { Search } from 'lucide-react';
import { Link } from 'react-router-dom';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Input } from '#shared/ui/Input';
import { AppLogo } from '../../shared';
export function SidebarHeader() {
const handleInputChange = () => {};
return (
<div className="mb-3.5">
<div className="flex items-center justify-between gap-2.5 px-3.5 h-17.5">
<Link to="/">
<img
src={toAbsoluteUrl('/media/app/logo-light.svg')}
className="dark:hidden h-10.5"
alt=""
/>
<img
src={toAbsoluteUrl('/media/app/logo-dark.svg')}
className="hidden dark:inline-block h-10.5"
alt=""
/>
</Link>
<div className="dark:text-[#c08183] shrink-0">
<div className="flex h-17.5 items-center justify-between gap-2.5 px-3.5">
<AppLogo className="h-10.5" />
<div className="shrink-0 dark:text-[#c08183]">
C R U X <sub className="text-[50%]">Goat</sub>
</div>
</div>
<div className="pt-2.5 px-3.5 mb-1">
<div className="mb-1 px-3.5 pt-2.5">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 start-3.5 -translate-y-1/2 size-4" />
<Search className="text-muted-foreground absolute start-3.5 top-1/2 size-4 -translate-y-1/2" />
<Input
placeholder="Search"
onChange={handleInputChange}
className="px-9 min-w-0"
className="min-w-0 px-9"
value=""
/>
<span className="text-xs text-muted-foreground absolute end-3.5 top-1/2 -translate-y-1/2">
<span className="text-muted-foreground absolute end-3.5 top-1/2 -translate-y-1/2 text-xs">
cmd + /
</span>
</div>
@@ -1,9 +1,9 @@
import { SidebarMenuPrimary } from './SidebarMenuPrimary';
import { SidebarAccordionMenu } from '../../shared';
export function SidebarMenu() {
return (
<div className="kt-scrollable-y-auto grow max-h-[calc(100vh-11.5rem)]">
<SidebarMenuPrimary />
<div className="kt-scrollable-y-auto max-h-[calc(100vh-11.5rem)] grow">
<SidebarAccordionMenu />
</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
// https://keenthemes.com/metronic/tailwind/react/demo6
import React from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { Fragment } from 'react';
import { Outlet } from 'react-router-dom';
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#providers/SettingsProvider';
import { Footer } from './components/Footer';
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar';
import { Toolbar, ToolbarHeading } from './components/Toolbar';
export const MultipleMenusLayout = () => {
const isMobile = useIsMobile();
const { setOption } = useSettings();
const { pathname } = useLocation();
useLayoutSettings(LayoutNames.MULTIPLE_MENUS);
const { showToolbar, showFooter } = useLayoutChrome();
useBodyClass(`
[--header-height:60px]
@@ -23,46 +21,29 @@ export const MultipleMenusLayout = () => {
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 (
<React.Fragment>
<Fragment>
<div className="flex grow">
{!isMobile && <Sidebar />}
{isMobile && <Header />}
<div className="flex flex-col lg:flex-row grow pt-(--header-height) lg:pt-0">
<div className="overflow-hidden flex flex-col grow items-stretch rounded-xl bg-background border border-input lg:ms-(--sidebar-width) mt-0 lg:mt-3.75 m-3.75">
<div className="flex flex-col grow kt-scrollable-y-auto [--kt-scrollbar-width:auto] pt-5">
<div className="flex grow flex-col pt-(--header-height) lg:flex-row lg:pt-0">
<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]">
<main className="grow" role="content">
{toolbar}
{showToolbar && (
<Toolbar>
<ToolbarHeading />
</Toolbar>
)}
<Outlet />
</main>
</div>
{footer}
{showFooter && <AppFooter />}
</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 md:flex-row justify-center md:justify-between items-center gap-3 py-5">
<div className="flex order-2 md:order-1 gap-2 font-normal text-sm">
<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="flex order-1 md:order-2 gap-4 font-normal text-sm text-muted-foreground">
<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>
);
}
@@ -2,41 +2,44 @@ import { useEffect, useState } from 'react';
import { useScrollPosition } from '@crux-kit/hooks';
import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
import { Container } from '#shared/widgets/common/Container';
import { useSettings } from '#providers/SettingsProvider';
import { UserAvatarMenu } from '../../shared';
import { HeaderLogo } from './HeaderLogo';
import { HeaderTopbar } from './HeaderTopbar';
const Header = () => {
const { settings } = useSettings();
const scrollPosition = useScrollPosition();
const [headerStickyOn, setHeaderStickyOn] = useState(false);
useEffect(() => {
const isSticky =
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
setHeaderStickyOn(isSticky);
}, [scrollPosition, settings]);
const { headerSticky, headerStickyOffset } = settings.layouts[LayoutNames.ADVANCE_MEGA_MENU];
useEffect(() => {
if (headerStickyOn === true) {
setHeaderStickyOn(scrollPosition > headerStickyOffset);
}, [scrollPosition, headerStickyOffset]);
useEffect(() => {
if (headerStickyOn) {
document.body.setAttribute('data-sticky-header', 'on');
} else {
document.body.removeAttribute('data-sticky-header');
}
return () => document.body.removeAttribute('data-sticky-header');
}, [headerStickyOn]);
return (
<header
className={cn(
'flex items-center transition-[height] shrink-0 bg-background py-4 lg:py-0 lg:h-(--header-height)',
headerStickyOn &&
'transition-[height] fixed z-10 top-0 left-0 right-0 shadow-xs backdrop-blur-md bg-background/70 pe-(--removed-body-scroll-bar-size,0px)'
'bg-background flex shrink-0 items-center py-4 transition-[height] lg:h-(--header-height) lg:py-0',
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]'
)}
>
<Container className="flex flex-wrap gap-2 items-center lg:gap-4">
<Container className="flex flex-wrap items-center gap-2 lg:gap-4">
<HeaderLogo />
<HeaderTopbar />
<UserAvatarMenu />
</Container>
</header>
);
@@ -1,12 +1,10 @@
import { Menu } from 'lucide-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 { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { MegaMenu } from './MegaMenu';
import { MegaMenuMobile } from './MegaMenuMobile';
import { AppLogo, AppWordmark, MegaNavMenu, SidebarAccordionMenu } from '../../shared';
const HeaderLogo = () => {
const { pathname } = useLocation();
@@ -18,26 +16,14 @@ const HeaderLogo = () => {
}, [pathname]);
return (
<div className="flex items-stretch gap-1.5 lg:gap-10 grow">
<div className="flex grow items-stretch gap-1.5 lg:gap-10">
<div className="flex items-center gap-2.5">
<Link to="/">
<img
src={toAbsoluteUrl('/media/app/mini-logo-circle-primary.svg')}
className="dark:hidden min-h-8.5"
alt="logo"
/>
<img
src={toAbsoluteUrl('/media/app/mini-logo-circle-primary-dark.svg')}
className="hidden dark:inline-block min-h-8.5"
alt="logo"
/>
</Link>
<h3 className="text-mono text-lg font-medium hidden lg:block">Metronic</h3>
<AppLogo className="size-8.5" />
<AppWordmark className="text-mono hidden text-lg font-medium lg:block" />
</div>
{!isMobile ? (
<MegaMenu />
<MegaNavMenu />
) : (
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild>
@@ -45,10 +31,10 @@ const HeaderLogo = () => {
<Menu />
</Button>
</SheetTrigger>
<SheetContent className="p-0 gap-0 w-68.75" side="left" close={false}>
<SheetHeader className="p-0 space-y-0" />
<SheetBody className="p-0 flex flex-col grow">
<MegaMenuMobile />
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" />
<SheetBody className="flex grow flex-col overflow-y-auto py-5">
<SidebarAccordionMenu />
</SheetBody>
</SheetContent>
</Sheet>

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