Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0263164a58 | ||
|
|
1b6840f654 | ||
|
|
aae77ebc27 | ||
|
|
3556662ddd | ||
|
|
ae34b30e4f | ||
|
|
93fc6ff5a0 | ||
|
|
e8558c076e | ||
|
|
c93d8c025b | ||
|
|
fdb2cf3837 | ||
|
|
1d51ece993 | ||
|
|
827dc22729 | ||
|
|
4f7003dde9 | ||
|
|
0ba01cd111 | ||
|
|
ba691d3a0c | ||
|
|
fe258e5895 | ||
|
|
bbdf4a3312 | ||
|
|
e98a1e85f0 | ||
|
|
2232ef0029 |
@@ -1,3 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
.next
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
.git
|
.git
|
||||||
|
|||||||
@@ -26,5 +26,7 @@ yarn-error.log
|
|||||||
# Platform specific
|
# Platform specific
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
/backend/public/
|
*.tsbuildinfo
|
||||||
/backend/storage/
|
|
||||||
|
/apps/backend/public/
|
||||||
|
/apps/backend/storage/
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
npm run test:admin
|
npm run test:admin
|
||||||
|
npm run test:backend
|
||||||
|
npm run test:web
|
||||||
|
|||||||
@@ -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/**
|
||||||
@@ -7,15 +7,122 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
### Changed
|
### 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
|
- Refactored admin module-generator source builders to compose imports and source sections through
|
||||||
shared source-file helpers while preserving the existing generation payload.
|
shared source-file helpers while preserving the existing generation payload.
|
||||||
- Renamed module-generator builder classes and files from template-builder terminology to
|
- Renamed module-generator builder classes and files from template-builder terminology to
|
||||||
source-builder terminology.
|
source-builder terminology.
|
||||||
- Updated module-generator frontend documentation to reference the new source-builder names.
|
- Updated module-generator frontend documentation to reference the new source-builder names.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
- Fix prettier one line import break sorting and class order
|
- Fix prettier one line import break sorting and class order
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
- Removed "prettier-plugin-one-line-imports"
|
- Removed "prettier-plugin-one-line-imports"
|
||||||
|
|
||||||
## [1.0.1] - 2026-06-30
|
## [1.0.1] - 2026-06-30
|
||||||
|
|||||||
@@ -1,3 +1,18 @@
|
|||||||
|
# CRUX
|
||||||
|
|
||||||
|
A monorepo starter for products that need an admin panel, an API, and a public website. It ships an
|
||||||
|
AdonisJS backend, a React admin panel with a module generator, and a Next.js website that renders
|
||||||
|
the pages editors publish through the built-in CMS.
|
||||||
|
|
||||||
|
| App | Source | Dev port | What it is |
|
||||||
|
| --------------- | --------------- | -------- | ------------------------------------------------------ |
|
||||||
|
| Backend API | `apps/backend/` | 3333 | AdonisJS — data, auth, permissions, public content API |
|
||||||
|
| Admin panel | `apps/admin/` | 5173 | React + Vite — the internal UI |
|
||||||
|
| Public website | `apps/web/` | 3000 | Next.js — the site visitors see |
|
||||||
|
| Shared packages | `packages/` | — | UI, hooks, services, types, utils |
|
||||||
|
|
||||||
|
Full documentation lives in [`docs/`](./docs/Readme.md).
|
||||||
|
|
||||||
### Tech Stack
|
### Tech Stack
|
||||||
|
|
||||||
#### Backend
|
#### Backend
|
||||||
@@ -6,94 +21,157 @@
|
|||||||
- TypeScript
|
- TypeScript
|
||||||
- Lucid ORM
|
- Lucid ORM
|
||||||
- PostgreSQL / MySQL (configurable)
|
- PostgreSQL / MySQL (configurable)
|
||||||
|
- Redis, S3 / MinIO storage
|
||||||
|
|
||||||
#### Frontend
|
#### Admin panel
|
||||||
|
|
||||||
- React
|
- React
|
||||||
- TypeScript
|
- TypeScript
|
||||||
- Vite
|
- Vite
|
||||||
- React Query / Mobx / Axios
|
- React Query / MobX / Axios
|
||||||
- Component-based UI system
|
- Radix UI + Tailwind component system
|
||||||
|
- React Hook Form + Zod
|
||||||
|
|
||||||
|
#### Public website
|
||||||
|
|
||||||
|
- Next.js (App Router, React Server Components)
|
||||||
|
- TypeScript
|
||||||
|
- next-intl
|
||||||
|
- Tailwind CSS
|
||||||
|
|
||||||
### Getting Started
|
### Getting Started
|
||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
Node.js >= 22.14.0
|
- Node.js `24.18.0` (see `.nvmrc`; the package manifest requires `>=24.0.0`)
|
||||||
npm >= 11.6.2
|
- npm `>=11.6.2` (the repo pins `npm@11.15.0`)
|
||||||
Database PostgreSQL
|
- PostgreSQL
|
||||||
|
|
||||||
#### Installation
|
#### Installation
|
||||||
|
|
||||||
```
|
```bash
|
||||||
git clone gitlab@git.rexsoftproduction.com:crux/admin.git
|
git clone gitlab@git.rexsoftproduction.com:crux/admin.git
|
||||||
cd crux
|
|
||||||
npm install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd crux && npm install --force
|
||||||
|
```
|
||||||
|
|
||||||
|
> `--force` is required. `typescript-eslint` does not yet declare support for the TypeScript
|
||||||
|
> version this repo uses, so a plain `npm install` fails with `ERESOLVE`. Never substitute
|
||||||
|
> `--legacy-peer-deps` — it strips nested peer dependencies and breaks the backend at runtime. Use
|
||||||
|
> `npm ci --force` for a reproducible install, and `npm install <package> --force` when adding one.
|
||||||
|
> See [troubleshooting.md](./troubleshooting.md).
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Environment variables are defined in root `.env`
|
Each workspace defines its own environment variables. Copy the workspace's `.env.example` to `.env` and fill in the values:
|
||||||
|
|
||||||
```
|
| Env file | Covers |
|
||||||
TZ=UTC
|
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
PORT=3333
|
| `apps/backend/.env` | Server (`PORT`, `APP_KEY`), database (`DB_*`), storage (`AWS_*`, `S3_*`), and the public site link (`WEB_APP_URL`, `WEB_REVALIDATE_SECRET`) used to purge its cache |
|
||||||
HOST=localhost
|
| `apps/admin/.env` | `VITE_API_URL` (backend), `VITE_WEB_URL` (public site, used for page previews), `VITE_TINYMCE_API_KEY` |
|
||||||
LOG_LEVEL=info
|
| `apps/web/.env` | `BACKEND_API_URL` (backend base URL for server rendering), `REVALIDATE_SECRET` (must match the backend's `WEB_REVALIDATE_SECRET`), `SITE_URL` (canonical origin) |
|
||||||
APP_KEY=
|
|
||||||
NODE_ENV=development
|
`docker-compose.yml` loads each service's env from the same per-app files. Inside Docker, service
|
||||||
DB_HOST=127.0.0.1
|
URLs must use compose service names (`http://backend:3333`), not `localhost`.
|
||||||
DB_PORT=5432
|
|
||||||
DB_USER=postgres
|
### Running
|
||||||
DB_PASSWORD=postgres
|
|
||||||
DB_DATABASE=adonis
|
#### Locally
|
||||||
VITE_API_URL=http://0.0.0.0:3333
|
|
||||||
VITE_TINYMCE_API_KEY=
|
| Command | Description |
|
||||||
|
| ----------------------- | ----------------------------------------------- |
|
||||||
|
| `npm run dev:backend` | Start the AdonisJS backend in development mode |
|
||||||
|
| `npm run dev:admin-app` | Start the React admin application |
|
||||||
|
| `npm run dev:web` | Start the Next.js web application |
|
||||||
|
| `npm run dev:all` | Start backend, admin, and web apps concurrently |
|
||||||
|
|
||||||
|
#### With Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Module Generator
|
Brings up everything: backend (3333), admin (5173), web (3000), PostgreSQL (5432), Redis (6379),
|
||||||
|
MinIO (9000 / console 9001) and pgAdmin (5050).
|
||||||
|
|
||||||
The module generator allows you to create full CRUD modules by selecting:
|
Two quirks worth knowing when a change appears not to take effect:
|
||||||
|
|
||||||
|
- The web container keeps `.next` on an anonymous volume that survives rebuilds. Clear it with
|
||||||
|
`docker compose up -d --force-recreate --renew-anon-volumes web`.
|
||||||
|
- Backend HMR does not always pick up edits on macOS (bind-mount file events); restart
|
||||||
|
`crux_backend_adonis` to load them.
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
#### Build
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| ----------------------- | ------------------------------------------ |
|
||||||
|
| `npm run build:backend` | Build the backend application |
|
||||||
|
| `npm run build:admin` | Type-check and build the admin application |
|
||||||
|
| `npm run build:web` | Type-check and build the Next.js web app |
|
||||||
|
|
||||||
|
#### Test
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| ---------------------- | ---------------------------------------------- |
|
||||||
|
| `npm run test:backend` | Backend functional tests (requires a database) |
|
||||||
|
| `npm run test:admin` | Admin unit tests (Vitest) |
|
||||||
|
| `npm run test:web` | Web app unit tests (Vitest) |
|
||||||
|
|
||||||
|
#### Lint and format
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| ---------------------- | -------------------------- |
|
||||||
|
| `npm run lint` | ESLint across the monorepo |
|
||||||
|
| `npm run lint:fix` | ESLint with autofix |
|
||||||
|
| `npm run format` | Prettier write |
|
||||||
|
| `npm run format:check` | Prettier check, no writes |
|
||||||
|
|
||||||
|
#### Versions
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| ----------------------- | ------------------------------------ |
|
||||||
|
| `npm run version:patch` | Update patch version of all packages |
|
||||||
|
| `npm run version:minor` | Update minor version of all packages |
|
||||||
|
| `npm run version:major` | Update major version of all packages |
|
||||||
|
|
||||||
|
> Versions are updated without creating git tags.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
#### Module Generator
|
||||||
|
|
||||||
|
Create full CRUD modules by selecting:
|
||||||
|
|
||||||
- Database table
|
- Database table
|
||||||
- Fields and types
|
- Fields and types
|
||||||
- Validation rules
|
- Validation rules
|
||||||
- List view configuration
|
- List view configuration
|
||||||
|
|
||||||
#### Generated Output
|
**Generated output** — Backend: controllers, validators, routes. Frontend: pages, forms, tables, API.
|
||||||
|
|
||||||
**Backend**: controllers, validators, routes
|
#### Authentication & Authorization
|
||||||
**Frontend**: pages, forms, tables, API
|
|
||||||
|
|
||||||
### Authentication & Authorization
|
- JWT / session-based authentication
|
||||||
|
|
||||||
- JWT / Session-based authentication
|
|
||||||
- Role & permission system
|
- Role & permission system
|
||||||
- Policy-based access control
|
- Policy-based access control
|
||||||
|
|
||||||
### Scripts
|
#### Content Management
|
||||||
|
|
||||||
#### Development
|
Web Manager is the built-in CMS: editors build localized pages from prebuilt blocks in the admin
|
||||||
|
panel, preview them privately, and publish to the Next.js site, which purges only the cache entries
|
||||||
|
the change affects. See [Web Manager](./docs/core/web-manager/Readme.md).
|
||||||
|
|
||||||
| Command | Description |
|
### Documentation
|
||||||
| ----------------------- | ---------------------------------------------- |
|
|
||||||
| `npm run dev:backend` | Start the AdonisJS backend in development mode |
|
|
||||||
| `npm run dev:admin-app` | Start the React admin application |
|
|
||||||
| `npm run dev:all` | Start backend and admin app concurrently |
|
|
||||||
|
|
||||||
#### Build
|
| Topic | Where |
|
||||||
|
| ------------------------ | ---------------------------------------------------- |
|
||||||
| Command | Description |
|
| Documentation home | [docs/Readme.md](./docs/Readme.md) |
|
||||||
| ----------------------- | ----------------------------- |
|
| Public website | [apps/web/Readme.md](./apps/web/Readme.md) |
|
||||||
| `npm run build:backend` | Build the backend application |
|
| Architecture | [docs/architecture/](./docs/architecture/Readme.md) |
|
||||||
| `npm run build:admin` | Build the admin application |
|
| Core modules | [docs/core/](./docs/Readme.md#core-modules) |
|
||||||
|
| Shared packages | [docs/packages/Readme.md](./docs/packages/Readme.md) |
|
||||||
#### Versions
|
| Install and setup issues | [troubleshooting.md](./troubleshooting.md) |
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
| --------------- | ------------------------------------ |
|
|
||||||
| `npm run patch` | Update Patch version of all packages |
|
|
||||||
| `npm run minor` | Update Minor version of all packages |
|
|
||||||
| `npm run major` | Update Major version of all packages |
|
|
||||||
|
|
||||||
> Versions are updated without creating git tags.
|
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
VITE_API_URL=http://0.0.0.0:3333
|
VITE_API_URL=http://localhost:3333
|
||||||
VITE_TINYMCE_API_KEY=
|
VITE_TINYMCE_API_KEY=
|
||||||
|
VITE_WEB_URL=http://localhost:3000
|
||||||
|
VITE_LAYOUT=
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
FROM node:22-alpine3.20
|
FROM node:24-alpine3.22
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install workspace deps
|
# Copy all workspaces before installation so npm resolves workspace dependencies.
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
COPY apps ./apps
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Copy project
|
|
||||||
COPY apps/admin ./apps/admin
|
|
||||||
COPY packages ./packages
|
COPY packages ./packages
|
||||||
|
|
||||||
|
RUN npm ci --force
|
||||||
|
|
||||||
WORKDIR /app/apps/admin
|
WORKDIR /app/apps/admin
|
||||||
|
|
||||||
ENV CHOKIDAR_USEPOLLING=true
|
ENV CHOKIDAR_USEPOLLING=true
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
# Metronic 9 | All-in-One Tailwind based HTML/React/Next.js Template for Modern Web Applications
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 22.14.0 or higher
|
|
||||||
- Npm or Yarn
|
|
||||||
- Tailwind CSS 4.x
|
|
||||||
- React 19.x
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@crux-kit/admin-app",
|
"name": "@crux-kit/admin-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.1",
|
"version": "1.3.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@crux-kit/editor-tiptap": "*",
|
||||||
"@crux-kit/hooks": "*",
|
"@crux-kit/hooks": "*",
|
||||||
"@crux-kit/intl-date": "*",
|
"@crux-kit/intl-date": "*",
|
||||||
"@crux-kit/intl-name": "*",
|
"@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 |
@@ -4,6 +4,8 @@ export const TINYMCE_API_KEY = import.meta.env['VITE_TINYMCE_API_KEY'];
|
|||||||
|
|
||||||
export const API_URL = import.meta.env['VITE_API_URL'];
|
export const API_URL = import.meta.env['VITE_API_URL'];
|
||||||
|
|
||||||
|
export const LAYOUT = import.meta.env['VITE_LAYOUT'];
|
||||||
|
|
||||||
export const IS_DEV_MODE = import.meta.env.MODE === 'development';
|
export const IS_DEV_MODE = import.meta.env.MODE === 'development';
|
||||||
|
|
||||||
export const IS_PROD_MODE = import.meta.env.MODE === 'production';
|
export const IS_PROD_MODE = import.meta.env.MODE === 'production';
|
||||||
|
|||||||
@@ -1,28 +1,30 @@
|
|||||||
import { LayoutNames, Settings } from './types';
|
import { LayoutNames, LayoutOptions, Settings } from './types';
|
||||||
|
|
||||||
|
const DEFAULT_LAYOUT_OPTIONS: LayoutOptions = {
|
||||||
|
sidebarCollapse: false,
|
||||||
|
sidebarTheme: 'light',
|
||||||
|
headerSticky: true,
|
||||||
|
headerStickyOffset: 200
|
||||||
|
};
|
||||||
|
|
||||||
|
const layouts = Object.fromEntries(
|
||||||
|
Object.values(LayoutNames).map((layout) => [layout, { ...DEFAULT_LAYOUT_OPTIONS }])
|
||||||
|
) as Record<LayoutNames, LayoutOptions>;
|
||||||
|
|
||||||
|
export const DEFAULT_CONTAINER_BY_LAYOUT: Record<LayoutNames, 'fixed' | 'fluid'> = {
|
||||||
|
[LayoutNames.DEFAULT]: 'fixed',
|
||||||
|
[LayoutNames.HORIZONTAL_MENU]: 'fixed',
|
||||||
|
[LayoutNames.COMPACT_SIDEBAR]: 'fixed',
|
||||||
|
[LayoutNames.TWO_COLUMN_SIDEBAR]: 'fixed',
|
||||||
|
[LayoutNames.DUAL_ROW_HEADER]: 'fixed',
|
||||||
|
[LayoutNames.MULTIPLE_MENUS]: 'fixed',
|
||||||
|
[LayoutNames.ADVANCE_MEGA_MENU]: 'fluid',
|
||||||
|
[LayoutNames.DROPDOWN_MENU]: 'fixed',
|
||||||
|
[LayoutNames.EXTENDED_HEADER]: 'fixed',
|
||||||
|
[LayoutNames.DARK_SIDEBAR]: 'fixed'
|
||||||
|
};
|
||||||
|
|
||||||
export const APP_SETTINGS: Settings = {
|
export const APP_SETTINGS: Settings = {
|
||||||
layout: '',
|
|
||||||
container: 'fixed',
|
container: 'fixed',
|
||||||
layouts: {
|
layouts
|
||||||
[LayoutNames.DEFAULT]: {
|
|
||||||
sidebarCollapse: false,
|
|
||||||
sidebarTheme: 'light'
|
|
||||||
},
|
|
||||||
[LayoutNames.HORIZONTAL_MENU]: {
|
|
||||||
headerSticky: true,
|
|
||||||
headerStickyOffset: 200
|
|
||||||
},
|
|
||||||
[LayoutNames.DUAL_ROW_HEADER]: {
|
|
||||||
headerSticky: true,
|
|
||||||
headerStickyOffset: 200
|
|
||||||
},
|
|
||||||
[LayoutNames.ADVANCE_MEGA_MENU]: {
|
|
||||||
headerSticky: true,
|
|
||||||
headerStickyOffset: 200
|
|
||||||
},
|
|
||||||
[LayoutNames.EXTENDED_HEADER]: {
|
|
||||||
headerSticky: true,
|
|
||||||
headerStickyOffset: 200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,29 +33,14 @@ export enum LayoutNames {
|
|||||||
DARK_SIDEBAR = 'dark-sidebar-layout', // demo10
|
DARK_SIDEBAR = 'dark-sidebar-layout', // demo10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LayoutOptions {
|
||||||
|
sidebarCollapse: boolean;
|
||||||
|
sidebarTheme: 'light' | 'dark';
|
||||||
|
headerSticky: boolean;
|
||||||
|
headerStickyOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
container: 'fixed' | 'fluid';
|
container: 'fixed' | 'fluid';
|
||||||
layout: string;
|
layouts: Record<LayoutNames, LayoutOptions>;
|
||||||
layouts: {
|
|
||||||
[LayoutNames.DEFAULT]: {
|
|
||||||
sidebarCollapse: boolean;
|
|
||||||
sidebarTheme: 'light' | 'dark';
|
|
||||||
};
|
|
||||||
[LayoutNames.HORIZONTAL_MENU]: {
|
|
||||||
headerSticky: boolean;
|
|
||||||
headerStickyOffset: number;
|
|
||||||
};
|
|
||||||
[LayoutNames.DUAL_ROW_HEADER]: {
|
|
||||||
headerSticky: boolean;
|
|
||||||
headerStickyOffset: number;
|
|
||||||
};
|
|
||||||
[LayoutNames.ADVANCE_MEGA_MENU]: {
|
|
||||||
headerSticky: boolean;
|
|
||||||
headerStickyOffset: number;
|
|
||||||
};
|
|
||||||
[LayoutNames.EXTENDED_HEADER]: {
|
|
||||||
headerSticky: boolean;
|
|
||||||
headerStickyOffset: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
@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 */
|
/* ReUI Configs */
|
||||||
@import './config.reui.css';
|
@import './config.reui.css';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
import { type I18nProviderProps } from '#app/i18n/types';
|
||||||
|
|
||||||
|
export default createContext<I18nProviderProps | null>(null);
|
||||||
@@ -1,7 +1,105 @@
|
|||||||
{
|
{
|
||||||
|
"ACTION.BACK": "رجوع",
|
||||||
|
"ACTION.SAVE": "حفظ",
|
||||||
|
"ACTION.CREATE": "إنشاء",
|
||||||
|
"ACTION.INCLUDE": "تضمين",
|
||||||
|
"ACTION.GENERATE": "توليد",
|
||||||
|
"ACTION.REMOVE": "إزالة",
|
||||||
|
"ACTION.CANCEL": "إلغاء",
|
||||||
|
"ACTION.CONFIRM": "تأكيد",
|
||||||
|
"ACTION.SUBMIT": "إرسال",
|
||||||
|
"ACTION.SEARCH": "بحث",
|
||||||
|
"ACTION.SWAP": "تبديل",
|
||||||
|
"ACTION.MERGE": "دمج",
|
||||||
|
"ACTION.SPLIT": "تقسيم",
|
||||||
|
"ACTION.RESET": "إعادة تعيين",
|
||||||
|
"ACTION.PREVIEW": "معاينة",
|
||||||
|
"ACTION.UPLOAD": "رفع",
|
||||||
|
"ACTION.DELETE": "حذف",
|
||||||
|
"ACTION.CLOSE": "إغلاق",
|
||||||
|
"ACTION.CLEAR": "مسح",
|
||||||
|
"ACTION.CLEAR_FILTERS": "مسح عوامل التصفية",
|
||||||
|
"ACTION.EDIT": "تعديل",
|
||||||
|
"STATE.CREATING": "جارٍ الإنشاء",
|
||||||
|
"STATE.GENERATING": "جارٍ التوليد",
|
||||||
|
"STATE.SAVING": "جارٍ الحفظ",
|
||||||
|
"STATE.REMOVING": "جارٍ الإزالة",
|
||||||
|
"STATE.LOADING": "جارٍ التحميل",
|
||||||
|
"STATE.UPDATING": "جارٍ التحديث",
|
||||||
|
"ERROR": "خطأ",
|
||||||
|
"PERMISSION": "صلاحية",
|
||||||
|
"PERMISSION.PERMISSIONS": "الصلاحيات",
|
||||||
|
"PERMISSION.PERMISSION_NAME": "اسم الصلاحية",
|
||||||
|
"PERMISSION.SLUG": "Slug",
|
||||||
|
"PERMISSION.EDIT_PERMISSION": "تعديل الصلاحية",
|
||||||
|
"ROLE": "دور",
|
||||||
|
"ROLE.ROLES": "الأدوار",
|
||||||
|
"ROLE.ROLE_NAME": "اسم الدور",
|
||||||
|
"ROLE.SLUG": "Slug",
|
||||||
|
"ROLE.EDIT_ROLE": "تعديل الدور",
|
||||||
|
"USER": "مستخدم",
|
||||||
|
"USER.EDIT_USER": "تعديل المستخدم",
|
||||||
|
"USER.STATUS.ACTIVE": "نشط",
|
||||||
|
"USER.STATUS.INACTIVE": "غير نشط",
|
||||||
"USER.MENU.MY_PROFILE": "الملف الشخصي الخاص بي",
|
"USER.MENU.MY_PROFILE": "الملف الشخصي الخاص بي",
|
||||||
"USER.MENU.MY_ACCOUNT": "حسابي",
|
"USER.MENU.MY_ACCOUNT": "حسابي",
|
||||||
|
"USER.MENU.LANGUAGE": "لغة",
|
||||||
"USER.MENU.DARK_MODE": "الوضع المظلم",
|
"USER.MENU.DARK_MODE": "الوضع المظلم",
|
||||||
"USER.MENU.LOGOUT": "تسجيل الخروج",
|
"USER.MENU.LOGOUT": "تسجيل الخروج",
|
||||||
"USER.MENU.LANGUAGE": "لغة"
|
"SUCCESS": "تم بنجاح",
|
||||||
|
"ACTIONS": "الإجراءات",
|
||||||
|
"FILTER": "تصفية",
|
||||||
|
"SELECTED": "محدد",
|
||||||
|
"NAME": "الاسم",
|
||||||
|
"FIRST_NAME": "الاسم الأول",
|
||||||
|
"LAST_NAME": "اسم العائلة",
|
||||||
|
"EMAIL": "البريد الإلكتروني",
|
||||||
|
"SELECT": "اختر",
|
||||||
|
"SELECT_OPTION": "اختر خياراً",
|
||||||
|
"SELECT_ALL": "تحديد الكل",
|
||||||
|
"SELECT_ALL_WITH_OPTS_LENGTH": "تحديد الكل ({length} خيارات)",
|
||||||
|
"STATUS": "الحالة",
|
||||||
|
"PASSWORD": "كلمة المرور",
|
||||||
|
"NEW_PASSWORD": "كلمة المرور الجديدة",
|
||||||
|
"PASSWORD_CONFIRM": "تأكيد كلمة المرور",
|
||||||
|
"MODULE_NAME": "اسم الوحدة",
|
||||||
|
"RETURN_HOME": "العودة إلى الرئيسية",
|
||||||
|
"NO_ITEMS_FOUND": "لم يتم العثور على عناصر",
|
||||||
|
"NO_RESULTS_FOUND": "لم يتم العثور على نتائج",
|
||||||
|
"PLUS_MORE_WITH_NUMBER": "+ {number} أخرى",
|
||||||
|
"ADMIN.ADMIN_MANAGEMENT": "إدارة المشرفين",
|
||||||
|
"ADMIN.ADMINS": "المشرفون",
|
||||||
|
"DASHBOARD": "لوحة التحكم",
|
||||||
|
"FORM.ERROR.REQUIRED": "مطلوب",
|
||||||
|
"FORM.ERROR.INVALID_EMAIL_ADDRESS": "عنوان بريد إلكتروني غير صالح",
|
||||||
|
"FORM.ERROR.PASSWORDS_MUST_MATCH": "يجب أن تتطابق كلمتا المرور",
|
||||||
|
"FORM.ERROR.MIN_PASSWORD_LENGTH_DESC": "الحد الأدنى لطول كلمة المرور - 8 أحرف.",
|
||||||
|
"FORM.ERROR.SOMETHING_WENT_WRONG": "حدث خطأ ما",
|
||||||
|
"TABLE.COMMON.COLUMNS": "الأعمدة",
|
||||||
|
"TABLE.COMMON.NO_DATA_AVAILABLE": "لا توجد بيانات متاحة",
|
||||||
|
"TABLE.COMMON.ROWS_PER_PAGE": "عدد الصفوف في الصفحة",
|
||||||
|
"TABLE.COMMON.GO_TO_PREVIOUS_PAGE": "الانتقال إلى الصفحة السابقة",
|
||||||
|
"TABLE.COMMON.GO_TO_NEXT_PAGE": "الانتقال إلى الصفحة التالية",
|
||||||
|
"PROFILE.PROFILE_SETTINGS": "إعدادات الملف الشخصي",
|
||||||
|
"PROFILE.AVATAR_DESCRIPTION": "نوصي باستخدام صور لا تقل أبعادها عن 98 × 98 بكسل (بحجم يصل إلى 4 ميغابايت) بصيغة PNG أو JPG أو JPEG.",
|
||||||
|
"PROFILE.AVATAR_ALT": "الصورة الرمزية",
|
||||||
|
"PROFILE.AVATAR_SETTINGS": "إعدادات الصورة الرمزية",
|
||||||
|
"PROFILE.AVATAR_CROP_ME": "قص الصورة",
|
||||||
|
"PROFILE.EDIT_PROFILE_IMAGE": "تعديل صورة الملف الشخصي",
|
||||||
|
"MODULE.COMMON.REMOVE_ITEM_QUESTION": "هل أنت متأكد من رغبتك في إزالة {target}؟",
|
||||||
|
"MODULE.COMMON.ARE_YOU_SURE?": "هل أنت متأكد؟",
|
||||||
|
"MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC": "لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"MODULE.COMMON.CREATE_NEW_ENTITY": "إنشاء {entity} جديد",
|
||||||
|
"MODULE.COMMON.CREATE_NEW_USER": "إنشاء مستخدم جديد",
|
||||||
|
"MESSAGE.PLEASE_WAIT": "يرجى الانتظار",
|
||||||
|
"MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS": "يرجى الانتظار، قد يستغرق هذا بعض الوقت",
|
||||||
|
"COMMON.SELECT_FILE_TO_VIEW_ITS_CONTENT": "اختر ملفاً لعرض محتوياته",
|
||||||
|
"COMPONENT.FILE_INPUT.SELECTED_NUM_OF_FILES": "تم اختيار {num} ملف",
|
||||||
|
"COMPONENT.FILE_INPUT.SELECT_FILE_NO_FILE_SELECTED": "اختر ملفاً، لم يتم اختيار أي ملف",
|
||||||
|
"COMPONENT.FILE_INPUT.OPTION.FROM_CLOUD_STORAGE": "من التخزين السحابي",
|
||||||
|
"COMPONENT.FILE_INPUT.OPTION.FROM_LOCAL_MACHINE": "من الجهاز المحلي",
|
||||||
|
"COMPONENT.FILE_INPUT.OPTION.CLEAR": "مسح",
|
||||||
|
"COMPONENT.FILE_INPUT.SHOW_SELECTED_FILES": "عرض الملفات المختارة",
|
||||||
|
"COMPONENT.FILE_INPUT.SELECTED_FILES": "الملفات المختارة",
|
||||||
|
"COMPONENT.FILE_INPUT.REMOVE_NAME": "إزالة {name}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,28 +20,23 @@
|
|||||||
"ACTION.CLEAR": "Clear",
|
"ACTION.CLEAR": "Clear",
|
||||||
"ACTION.CLEAR_FILTERS": "Clear filters",
|
"ACTION.CLEAR_FILTERS": "Clear filters",
|
||||||
"ACTION.EDIT": "Edit",
|
"ACTION.EDIT": "Edit",
|
||||||
|
|
||||||
"STATE.CREATING": "Creating",
|
"STATE.CREATING": "Creating",
|
||||||
"STATE.GENERATING": "Generating",
|
"STATE.GENERATING": "Generating",
|
||||||
"STATE.SAVING": "Saving",
|
"STATE.SAVING": "Saving",
|
||||||
"STATE.REMOVING": "Removing",
|
"STATE.REMOVING": "Removing",
|
||||||
"STATE.LOADING": "Loading",
|
"STATE.LOADING": "Loading",
|
||||||
"STATE.UPDATING": "Updating",
|
"STATE.UPDATING": "Updating",
|
||||||
|
|
||||||
"ERROR": "Error",
|
"ERROR": "Error",
|
||||||
|
|
||||||
"PERMISSION": "Permission",
|
"PERMISSION": "Permission",
|
||||||
"PERMISSION.PERMISSIONS": "Permissions",
|
"PERMISSION.PERMISSIONS": "Permissions",
|
||||||
"PERMISSION.PERMISSION_NAME": "Permission name",
|
"PERMISSION.PERMISSION_NAME": "Permission name",
|
||||||
"PERMISSION.SLUG": "Slug",
|
"PERMISSION.SLUG": "Slug",
|
||||||
"PERMISSION.EDIT_PERMISSION": "Edit permission",
|
"PERMISSION.EDIT_PERMISSION": "Edit permission",
|
||||||
|
|
||||||
"ROLE": "Role",
|
"ROLE": "Role",
|
||||||
"ROLE.ROLES": "Roles",
|
"ROLE.ROLES": "Roles",
|
||||||
"ROLE.ROLE_NAME": "Role name",
|
"ROLE.ROLE_NAME": "Role name",
|
||||||
"ROLE.SLUG": "Slug",
|
"ROLE.SLUG": "Slug",
|
||||||
"ROLE.EDIT_ROLE": "Edit role",
|
"ROLE.EDIT_ROLE": "Edit role",
|
||||||
|
|
||||||
"USER": "User",
|
"USER": "User",
|
||||||
"USER.EDIT_USER": "Edit user",
|
"USER.EDIT_USER": "Edit user",
|
||||||
"USER.STATUS.ACTIVE": "Active",
|
"USER.STATUS.ACTIVE": "Active",
|
||||||
@@ -51,7 +46,6 @@
|
|||||||
"USER.MENU.LANGUAGE": "Language",
|
"USER.MENU.LANGUAGE": "Language",
|
||||||
"USER.MENU.DARK_MODE": "Dark Mode",
|
"USER.MENU.DARK_MODE": "Dark Mode",
|
||||||
"USER.MENU.LOGOUT": "Logout",
|
"USER.MENU.LOGOUT": "Logout",
|
||||||
|
|
||||||
"SUCCESS": "Success",
|
"SUCCESS": "Success",
|
||||||
"ACTIONS": "Actions",
|
"ACTIONS": "Actions",
|
||||||
"FILTER": "Filter",
|
"FILTER": "Filter",
|
||||||
@@ -73,42 +67,33 @@
|
|||||||
"NO_ITEMS_FOUND": "No items found",
|
"NO_ITEMS_FOUND": "No items found",
|
||||||
"NO_RESULTS_FOUND": "No results found",
|
"NO_RESULTS_FOUND": "No results found",
|
||||||
"PLUS_MORE_WITH_NUMBER": "+ {number} more",
|
"PLUS_MORE_WITH_NUMBER": "+ {number} more",
|
||||||
|
|
||||||
"ADMIN.ADMIN_MANAGEMENT": "Admin Management",
|
"ADMIN.ADMIN_MANAGEMENT": "Admin Management",
|
||||||
"ADMIN.ADMINS": "Admins",
|
"ADMIN.ADMINS": "Admins",
|
||||||
|
|
||||||
"DASHBOARD": "Dashboard",
|
"DASHBOARD": "Dashboard",
|
||||||
|
|
||||||
"FORM.ERROR.REQUIRED": "Required",
|
"FORM.ERROR.REQUIRED": "Required",
|
||||||
"FORM.ERROR.INVALID_EMAIL_ADDRESS": "Invalid email address",
|
"FORM.ERROR.INVALID_EMAIL_ADDRESS": "Invalid email address",
|
||||||
"FORM.ERROR.PASSWORDS_MUST_MATCH": "Passwords must match",
|
"FORM.ERROR.PASSWORDS_MUST_MATCH": "Passwords must match",
|
||||||
"FORM.ERROR.MIN_PASSWORD_LENGTH_DESC": "Min. password length - 8 chars.",
|
"FORM.ERROR.MIN_PASSWORD_LENGTH_DESC": "Min. password length - 8 chars.",
|
||||||
"FORM.ERROR.SOMETHING_WENT_WRONG": "Something went wrong",
|
"FORM.ERROR.SOMETHING_WENT_WRONG": "Something went wrong",
|
||||||
|
|
||||||
"TABLE.COMMON.COLUMNS": "Columns",
|
"TABLE.COMMON.COLUMNS": "Columns",
|
||||||
"TABLE.COMMON.NO_DATA_AVAILABLE": "No data available",
|
"TABLE.COMMON.NO_DATA_AVAILABLE": "No data available",
|
||||||
"TABLE.COMMON.ROWS_PER_PAGE": "Rows per page",
|
"TABLE.COMMON.ROWS_PER_PAGE": "Rows per page",
|
||||||
"TABLE.COMMON.GO_TO_PREVIOUS_PAGE": "Go to previous page",
|
"TABLE.COMMON.GO_TO_PREVIOUS_PAGE": "Go to previous page",
|
||||||
"TABLE.COMMON.GO_TO_NEXT_PAGE": "Go to next page",
|
"TABLE.COMMON.GO_TO_NEXT_PAGE": "Go to next page",
|
||||||
|
|
||||||
"PROFILE.PROFILE_SETTINGS": "Profile Settings",
|
"PROFILE.PROFILE_SETTINGS": "Profile Settings",
|
||||||
"PROFILE.AVATAR_DESCRIPTION": "We recommend using images that are at least 98 x 98 pixels in size (file size up to 4 MB) in PNG, JPG, JPEG format.",
|
"PROFILE.AVATAR_DESCRIPTION": "We recommend using images that are at least 98 x 98 pixels in size (file size up to 4 MB) in PNG, JPG, JPEG format.",
|
||||||
"PROFILE.AVATAR_ALT": "Avatar",
|
"PROFILE.AVATAR_ALT": "Avatar",
|
||||||
"PROFILE.AVATAR_SETTINGS": "Avatar Settings",
|
"PROFILE.AVATAR_SETTINGS": "Avatar Settings",
|
||||||
"PROFILE.AVATAR_CROP_ME": "Crop me",
|
"PROFILE.AVATAR_CROP_ME": "Crop me",
|
||||||
"PROFILE.EDIT_PROFILE_IMAGE": "Edit profile image",
|
"PROFILE.EDIT_PROFILE_IMAGE": "Edit profile image",
|
||||||
|
|
||||||
"MODULE.COMMON.REMOVE_ITEM_QUESTION": "Are you sure you want to remove {target}?",
|
"MODULE.COMMON.REMOVE_ITEM_QUESTION": "Are you sure you want to remove {target}?",
|
||||||
"MODULE.COMMON.ARE_YOU_SURE?": "Are you sure?",
|
"MODULE.COMMON.ARE_YOU_SURE?": "Are you sure?",
|
||||||
"MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC": "This action cannot be undone.",
|
"MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC": "This action cannot be undone.",
|
||||||
"MODULE.COMMON.CREATE_NEW_ENTITY": "Create new {entity}",
|
"MODULE.COMMON.CREATE_NEW_ENTITY": "Create new {entity}",
|
||||||
"MODULE.COMMON.CREATE_NEW_USER": "Create new user",
|
"MODULE.COMMON.CREATE_NEW_USER": "Create new user",
|
||||||
|
|
||||||
"MESSAGE.PLEASE_WAIT": "Please wait",
|
"MESSAGE.PLEASE_WAIT": "Please wait",
|
||||||
"MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS": "Please wait, this may take few moments",
|
"MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS": "Please wait, this may take few moments",
|
||||||
|
|
||||||
"COMMON.SELECT_FILE_TO_VIEW_ITS_CONTENT": "Select a file to view its contents",
|
"COMMON.SELECT_FILE_TO_VIEW_ITS_CONTENT": "Select a file to view its contents",
|
||||||
|
|
||||||
"COMPONENT.FILE_INPUT.SELECTED_NUM_OF_FILES": "{num} file(s) selected",
|
"COMPONENT.FILE_INPUT.SELECTED_NUM_OF_FILES": "{num} file(s) selected",
|
||||||
"COMPONENT.FILE_INPUT.SELECT_FILE_NO_FILE_SELECTED": "Select file, no file selected",
|
"COMPONENT.FILE_INPUT.SELECT_FILE_NO_FILE_SELECTED": "Select file, no file selected",
|
||||||
"COMPONENT.FILE_INPUT.OPTION.FROM_CLOUD_STORAGE": "From cloud storage",
|
"COMPONENT.FILE_INPUT.OPTION.FROM_CLOUD_STORAGE": "From cloud storage",
|
||||||
|
|||||||
@@ -20,28 +20,23 @@
|
|||||||
"ACTION.CLEAR": "Очистити",
|
"ACTION.CLEAR": "Очистити",
|
||||||
"ACTION.CLEAR_FILTERS": "Очистити фільтри",
|
"ACTION.CLEAR_FILTERS": "Очистити фільтри",
|
||||||
"ACTION.EDIT": "Редагувати",
|
"ACTION.EDIT": "Редагувати",
|
||||||
|
|
||||||
"STATE.CREATING": "Створення",
|
"STATE.CREATING": "Створення",
|
||||||
"STATE.GENERATING": "Генерування",
|
"STATE.GENERATING": "Генерування",
|
||||||
"STATE.SAVING": "Збереження",
|
"STATE.SAVING": "Збереження",
|
||||||
"STATE.REMOVING": "Видалення",
|
"STATE.REMOVING": "Видалення",
|
||||||
"STATE.LOADING": "Завантаження",
|
"STATE.LOADING": "Завантаження",
|
||||||
"STATE.UPDATING": "Оновлюється",
|
"STATE.UPDATING": "Оновлюється",
|
||||||
|
|
||||||
"ERROR": "Помилка",
|
"ERROR": "Помилка",
|
||||||
|
|
||||||
"PERMISSION": "Дозвіл",
|
"PERMISSION": "Дозвіл",
|
||||||
"PERMISSION.PERMISSIONS": "Дозволи",
|
"PERMISSION.PERMISSIONS": "Дозволи",
|
||||||
"PERMISSION.PERMISSION_NAME": "Назва дозволу",
|
"PERMISSION.PERMISSION_NAME": "Назва дозволу",
|
||||||
"PERMISSION.SLUG": "Slug",
|
"PERMISSION.SLUG": "Slug",
|
||||||
"PERMISSION.EDIT_PERMISSION": "Редагувати дозвіл",
|
"PERMISSION.EDIT_PERMISSION": "Редагувати дозвіл",
|
||||||
|
|
||||||
"ROLE": "Роль",
|
"ROLE": "Роль",
|
||||||
"ROLE.ROLES": "Ролі",
|
"ROLE.ROLES": "Ролі",
|
||||||
"ROLE.ROLE_NAME": "Назва ролі",
|
"ROLE.ROLE_NAME": "Назва ролі",
|
||||||
"ROLE.SLUG": "Slug",
|
"ROLE.SLUG": "Slug",
|
||||||
"ROLE.EDIT_ROLE": "Редагувати роль",
|
"ROLE.EDIT_ROLE": "Редагувати роль",
|
||||||
|
|
||||||
"USER": "Користувач",
|
"USER": "Користувач",
|
||||||
"USER.EDIT_USER": "Редагувати користувача",
|
"USER.EDIT_USER": "Редагувати користувача",
|
||||||
"USER.STATUS.ACTIVE": "Активний",
|
"USER.STATUS.ACTIVE": "Активний",
|
||||||
@@ -51,7 +46,6 @@
|
|||||||
"USER.MENU.LANGUAGE": "Мова",
|
"USER.MENU.LANGUAGE": "Мова",
|
||||||
"USER.MENU.DARK_MODE": "Темний режим",
|
"USER.MENU.DARK_MODE": "Темний режим",
|
||||||
"USER.MENU.LOGOUT": "Вийти",
|
"USER.MENU.LOGOUT": "Вийти",
|
||||||
|
|
||||||
"SUCCESS": "Успіх",
|
"SUCCESS": "Успіх",
|
||||||
"ACTIONS": "Дії",
|
"ACTIONS": "Дії",
|
||||||
"FILTER": "Фільтр",
|
"FILTER": "Фільтр",
|
||||||
@@ -73,42 +67,33 @@
|
|||||||
"NO_ITEMS_FOUND": "Не знайдено жодного елемента",
|
"NO_ITEMS_FOUND": "Не знайдено жодного елемента",
|
||||||
"NO_RESULTS_FOUND": "Результатів не знайдено",
|
"NO_RESULTS_FOUND": "Результатів не знайдено",
|
||||||
"PLUS_MORE_WITH_NUMBER": "+ {number} ще",
|
"PLUS_MORE_WITH_NUMBER": "+ {number} ще",
|
||||||
|
|
||||||
"ADMIN.ADMIN_MANAGEMENT": "Адмін Управління",
|
"ADMIN.ADMIN_MANAGEMENT": "Адмін Управління",
|
||||||
"ADMIN.ADMINS": "Адміністратори",
|
"ADMIN.ADMINS": "Адміністратори",
|
||||||
|
|
||||||
"DASHBOARD": "Дашборд",
|
"DASHBOARD": "Дашборд",
|
||||||
|
|
||||||
"FORM.ERROR.REQUIRED": "Обов'язково",
|
"FORM.ERROR.REQUIRED": "Обов'язково",
|
||||||
"FORM.ERROR.INVALID_EMAIL_ADDRESS": "Недійсна адреса електронної пошти",
|
"FORM.ERROR.INVALID_EMAIL_ADDRESS": "Недійсна адреса електронної пошти",
|
||||||
"FORM.ERROR.PASSWORDS_MUST_MATCH": "Паролі мають збігатися",
|
"FORM.ERROR.PASSWORDS_MUST_MATCH": "Паролі мають збігатися",
|
||||||
"FORM.ERROR.MIN_PASSWORD_LENGTH_DESC": "Мінімальна довжина пароля - 8 символів.",
|
"FORM.ERROR.MIN_PASSWORD_LENGTH_DESC": "Мінімальна довжина пароля - 8 символів.",
|
||||||
"FORM.ERROR.SOMETHING_WENT_WRONG": "Щось пішло не так",
|
"FORM.ERROR.SOMETHING_WENT_WRONG": "Щось пішло не так",
|
||||||
|
|
||||||
"TABLE.COMMON.COLUMNS": "Колонки",
|
"TABLE.COMMON.COLUMNS": "Колонки",
|
||||||
"TABLE.COMMON.NO_DATA_AVAILABLE": "Немає доступних даних",
|
"TABLE.COMMON.NO_DATA_AVAILABLE": "Немає доступних даних",
|
||||||
"TABLE.COMMON.ROWS_PER_PAGE": "Рядків на сторінці",
|
"TABLE.COMMON.ROWS_PER_PAGE": "Рядків на сторінці",
|
||||||
"TABLE.COMMON.GO_TO_PREVIOUS_PAGE": "Перейти на попередню сторінку",
|
"TABLE.COMMON.GO_TO_PREVIOUS_PAGE": "Перейти на попередню сторінку",
|
||||||
"TABLE.COMMON.GO_TO_NEXT_PAGE": "Перейти на наступну сторінку",
|
"TABLE.COMMON.GO_TO_NEXT_PAGE": "Перейти на наступну сторінку",
|
||||||
|
|
||||||
"PROFILE.PROFILE_SETTINGS": "Налаштування профілю",
|
"PROFILE.PROFILE_SETTINGS": "Налаштування профілю",
|
||||||
"PROFILE.AVATAR_DESCRIPTION": "Радимо використовувати зображення розміром принаймні 98 x 98 пікселів (розмір файлу - до 4 МБ) у форматі PNG, JPG , JPEG.",
|
"PROFILE.AVATAR_DESCRIPTION": "Радимо використовувати зображення розміром принаймні 98 x 98 пікселів (розмір файлу - до 4 МБ) у форматі PNG, JPG , JPEG.",
|
||||||
"PROFILE.AVATAR_ALT": "Аватар",
|
"PROFILE.AVATAR_ALT": "Аватар",
|
||||||
"PROFILE.AVATAR_SETTINGS": "Налаштування аватара",
|
"PROFILE.AVATAR_SETTINGS": "Налаштування аватара",
|
||||||
"PROFILE.AVATAR_CROP_ME": "Обріж мене",
|
"PROFILE.AVATAR_CROP_ME": "Обріж мене",
|
||||||
"PROFILE.EDIT_PROFILE_IMAGE": "Редагувати зображення профілю",
|
"PROFILE.EDIT_PROFILE_IMAGE": "Редагувати зображення профілю",
|
||||||
|
|
||||||
"MODULE.COMMON.REMOVE_ITEM_QUESTION": "Ви впевнені, що хочете видалити {target}?",
|
"MODULE.COMMON.REMOVE_ITEM_QUESTION": "Ви впевнені, що хочете видалити {target}?",
|
||||||
"MODULE.COMMON.ARE_YOU_SURE?": "Ви впевнені?",
|
"MODULE.COMMON.ARE_YOU_SURE?": "Ви впевнені?",
|
||||||
"MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC": "Цю дію не можна скасувати.",
|
"MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC": "Цю дію не можна скасувати.",
|
||||||
"MODULE.COMMON.CREATE_NEW_ENTITY": "Створити новий {entity}",
|
"MODULE.COMMON.CREATE_NEW_ENTITY": "Створити новий {entity}",
|
||||||
"MODULE.COMMON.CREATE_NEW_USER": "Створити нового користувача",
|
"MODULE.COMMON.CREATE_NEW_USER": "Створити нового користувача",
|
||||||
|
|
||||||
"MESSAGE.PLEASE_WAIT": "Будь ласка, зачекайте",
|
"MESSAGE.PLEASE_WAIT": "Будь ласка, зачекайте",
|
||||||
"MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS": "Зачекайте хвилинку, це може зайняти деякий час",
|
"MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS": "Зачекайте хвилинку, це може зайняти деякий час",
|
||||||
|
|
||||||
"COMMON.SELECT_FILE_TO_VIEW_ITS_CONTENT": "Виберіть файл, щоб переглянути його вміст",
|
"COMMON.SELECT_FILE_TO_VIEW_ITS_CONTENT": "Виберіть файл, щоб переглянути його вміст",
|
||||||
|
|
||||||
"COMPONENT.FILE_INPUT.SELECTED_NUM_OF_FILES": "Вибрано {num} файл(и)",
|
"COMPONENT.FILE_INPUT.SELECTED_NUM_OF_FILES": "Вибрано {num} файл(и)",
|
||||||
"COMPONENT.FILE_INPUT.SELECT_FILE_NO_FILE_SELECTED": "Виберіть файл, файл не вибрано",
|
"COMPONENT.FILE_INPUT.SELECT_FILE_NO_FILE_SELECTED": "Виберіть файл, файл не вибрано",
|
||||||
"COMPONENT.FILE_INPUT.OPTION.FROM_CLOUD_STORAGE": "З хмарного сховища",
|
"COMPONENT.FILE_INPUT.OPTION.FROM_CLOUD_STORAGE": "З хмарного сховища",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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} ©</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 { useEffect, useState } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { useIsMobile, useScrollPosition } from '@crux-kit/hooks';
|
import { useIsMobile, useScrollPosition } from '@crux-kit/hooks';
|
||||||
import { cn } from '@crux-kit/utils';
|
import { cn } from '@crux-kit/utils';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
import { AppLogo, UserAvatarMenu } from '../../shared';
|
||||||
import { MegaMenu } from './MegaMenu';
|
|
||||||
import { MegaMenuMobile } from './MegaMenuMobile';
|
|
||||||
import { SidebarMenu } from './SidebarMenu';
|
import { SidebarMenu } from './SidebarMenu';
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const [isSidebarSheetOpen, setIsSidebarSheetOpen] = useState(false);
|
const [isSidebarSheetOpen, setIsSidebarSheetOpen] = useState(false);
|
||||||
const [isMegaMenuSheetOpen, setIsMegaMenuSheetOpen] = useState(false);
|
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const mobileMode = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const scrollPosition = useScrollPosition();
|
const scrollPosition = useScrollPosition();
|
||||||
const headerSticky: boolean = scrollPosition > 0;
|
const headerSticky: boolean = scrollPosition > 0;
|
||||||
|
|
||||||
// Close sheet when route changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSidebarSheetOpen(false);
|
setIsSidebarSheetOpen(false);
|
||||||
setIsMegaMenuSheetOpen(false);
|
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'header 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)',
|
'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-b border-border'
|
headerSticky && 'border-border border-b'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Container className="flex justify-between items-stretch lg:gap-4">
|
<Container className="flex items-stretch justify-between lg:gap-4">
|
||||||
{/* HeaderLogo */}
|
<div className="flex items-center gap-2.5 lg:hidden">
|
||||||
<div className="flex lg:hidden items-center gap-2.5">
|
<AppLogo className="h-6.25" />
|
||||||
<Link to="/" className="shrink-0">
|
|
||||||
<img
|
{isMobile && (
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo.svg')}
|
<Sheet open={isSidebarSheetOpen} onOpenChange={setIsSidebarSheetOpen}>
|
||||||
className="h-6.25 w-full"
|
<SheetTrigger asChild>
|
||||||
alt="mini-logo"
|
<Button variant="ghost" mode="icon">
|
||||||
/>
|
<Menu className="text-muted-foreground/70" />
|
||||||
</Link>
|
</Button>
|
||||||
<div className="flex items-center">
|
</SheetTrigger>
|
||||||
{mobileMode && (
|
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
||||||
<Sheet open={isSidebarSheetOpen} onOpenChange={setIsSidebarSheetOpen}>
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
<SheetTrigger asChild>
|
<SheetBody className="overflow-y-auto p-0">
|
||||||
<Button variant="ghost" mode="icon">
|
<SidebarMenu />
|
||||||
<Menu className="text-muted-foreground/70" />
|
</SheetBody>
|
||||||
</Button>
|
</SheetContent>
|
||||||
</SheetTrigger>
|
</Sheet>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content (MegaMenu) */}
|
<div className="flex grow items-center justify-end gap-3">
|
||||||
{!mobileMode && <MegaMenu />}
|
<UserAvatarMenu />
|
||||||
|
|
||||||
{/* HeaderTopbar */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<UserDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<img
|
|
||||||
className="size-9 rounded-full border-2 border-green-500 shrink-0 cursor-pointer"
|
|
||||||
src={toAbsoluteUrl('/media/avatars/300-2.png')}
|
|
||||||
alt="User Avatar"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import {
|
|
||||||
NavigationMenu,
|
|
||||||
// NavigationMenuContent,
|
|
||||||
// NavigationMenuItem,
|
|
||||||
// NavigationMenuLink,
|
|
||||||
NavigationMenuList
|
|
||||||
// NavigationMenuTrigger,
|
|
||||||
} from '#shared/ui/NavigationMenu';
|
|
||||||
|
|
||||||
// import { useMenu } from '#shared/hooks/use_menu';
|
|
||||||
// import { cn } from '@crux-kit/utils';
|
|
||||||
// import { MegaMenuSubAccount } from '#shared/widgets/mega_menu/mega_menu_sub_account';
|
|
||||||
// import { MegaMenuSubAuth } from '#shared/widgets/mega_menu/mega_menu_sub_auth';
|
|
||||||
// import { MegaMenuSubNetwork } from '#shared/widgets/mega_menu/mega_menu_sub_network';
|
|
||||||
// import { MegaMenuSubProfiles } from '#shared/widgets/mega_menu/mega_menu_sub_profiles';
|
|
||||||
// import { MegaMenuSubStore } from '#shared/widgets/mega_menu/mega_menu_sub_store';
|
|
||||||
|
|
||||||
export function MegaMenu() {
|
|
||||||
// const { pathname } = useLocation();
|
|
||||||
// const { isActive, hasActiveChild } = useMenu(pathname);
|
|
||||||
// const homeItem = MENU_MEGA[0];
|
|
||||||
// const publicProfilesItem = MENU_MEGA[1];
|
|
||||||
// const myAccountItem = MENU_MEGA[2];
|
|
||||||
// const networkItem = MENU_MEGA[3];
|
|
||||||
// const authItem = MENU_MEGA[4];
|
|
||||||
// const storeItem = MENU_MEGA[5];
|
|
||||||
|
|
||||||
// const linkClass = `
|
|
||||||
// text-sm text-secondary-foreground font-medium
|
|
||||||
// hover:text-primary hover:bg-transparent
|
|
||||||
// focus:text-primary focus:bg-transparent
|
|
||||||
// data-[active=true]:text-primary data-[active=true]:bg-transparent
|
|
||||||
// data-[state=open]:text-primary data-[state=open]:bg-transparent
|
|
||||||
// `;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavigationMenu>
|
|
||||||
<NavigationMenuList className="gap-0">
|
|
||||||
{/* Home Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuLink asChild>
|
|
||||||
<Link
|
|
||||||
to={homeItem.path || '/'}
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={isActive(homeItem.path) || undefined}
|
|
||||||
>
|
|
||||||
{homeItem.title}
|
|
||||||
</Link>
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* Public Profiles Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(publicProfilesItem.children) || undefined}
|
|
||||||
>
|
|
||||||
{publicProfilesItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubProfiles items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* My Account Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(myAccountItem.children) || undefined}
|
|
||||||
>
|
|
||||||
{myAccountItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubAccount items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* Network Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(networkItem.children || []) || undefined}
|
|
||||||
>
|
|
||||||
{networkItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubNetwork items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* Store Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(storeItem.children || []) || undefined}
|
|
||||||
>
|
|
||||||
{storeItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubStore items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* Authentication Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(authItem.children) || undefined}
|
|
||||||
>
|
|
||||||
{authItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubAuth items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
</NavigationMenuList>
|
|
||||||
</NavigationMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { LucideIcon } from 'lucide-react';
|
|
||||||
import { JSX, useCallback } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
// import { MENU_MEGA_MOBILE } from '#app/menu';
|
|
||||||
import {
|
|
||||||
AccordionMenu,
|
|
||||||
AccordionMenuClassNames,
|
|
||||||
AccordionMenuGroup,
|
|
||||||
AccordionMenuItem,
|
|
||||||
// AccordionMenuLabel,
|
|
||||||
AccordionMenuSub,
|
|
||||||
AccordionMenuSubContent,
|
|
||||||
AccordionMenuSubTrigger
|
|
||||||
} from '#shared/ui/AccordionMenu';
|
|
||||||
import { Badge } from '#shared/ui/Badge';
|
|
||||||
|
|
||||||
export interface MenuItem {
|
|
||||||
title?: string;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
path?: string;
|
|
||||||
rootPath?: string;
|
|
||||||
childrenIndex?: number;
|
|
||||||
heading?: string;
|
|
||||||
children?: MenuItem[];
|
|
||||||
disabled?: boolean;
|
|
||||||
collapse?: boolean;
|
|
||||||
collapseTitle?: string;
|
|
||||||
expandTitle?: string;
|
|
||||||
badge?: string;
|
|
||||||
separator?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MenuConfig = MenuItem[];
|
|
||||||
|
|
||||||
export function MegaMenuMobile() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
// Memoize matchPath to prevent unnecessary re-renders
|
|
||||||
const matchPath = useCallback(
|
|
||||||
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
|
|
||||||
[pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Global classNames for consistent styling
|
|
||||||
const classNames: AccordionMenuClassNames = {
|
|
||||||
root: 'space-y-1',
|
|
||||||
group: 'gap-px',
|
|
||||||
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
|
||||||
separator: '',
|
|
||||||
item: 'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
|
||||||
sub: '',
|
|
||||||
subTrigger:
|
|
||||||
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
|
||||||
subContent: 'py-0',
|
|
||||||
indicator: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
// const buildMenu = (items: MenuConfig): JSX.Element[] => {
|
|
||||||
// return items.map((item: MenuItem, index: number) => {
|
|
||||||
// if (item.heading) {
|
|
||||||
// return buildMenuHeading(item, index);
|
|
||||||
// } else if (!item.disabled) {
|
|
||||||
// return buildMenuItemRoot(item, index);
|
|
||||||
// } else {
|
|
||||||
// return <></>;
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
// if (item.children) {
|
|
||||||
// return (
|
|
||||||
// <AccordionMenuSub key={index} value={item.path || `root-${index}`}>
|
|
||||||
// <AccordionMenuSubTrigger className="text-sm font-medium">
|
|
||||||
// {item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
// <div className="flex items-center justify-between grow gap-2">
|
|
||||||
// <span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
// {item.badge && (
|
|
||||||
// <Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
// {item.badge}
|
|
||||||
// </Badge>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// </AccordionMenuSubTrigger>
|
|
||||||
// <AccordionMenuSubContent
|
|
||||||
// type="single"
|
|
||||||
// collapsible
|
|
||||||
// parentValue={item.path || `root-${index}`}
|
|
||||||
// className="ps-6"
|
|
||||||
// >
|
|
||||||
// <AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
|
|
||||||
// </AccordionMenuSubContent>
|
|
||||||
// </AccordionMenuSub>
|
|
||||||
// );
|
|
||||||
// } else {
|
|
||||||
// return (
|
|
||||||
// <AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
|
|
||||||
// <Link to={item.path || '#'} className="">
|
|
||||||
// {item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
// <div className="flex items-center justify-between grow gap-2">
|
|
||||||
// <span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
// {item.badge && (
|
|
||||||
// <Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
// {item.badge}
|
|
||||||
// </Badge>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// </Link>
|
|
||||||
// </AccordionMenuItem>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (!item.disabled) {
|
|
||||||
return buildMenuItemChild(item, index, level);
|
|
||||||
} else {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-[13px]">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
{item.collapse ? (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
<span className="hidden [[data-state=open]>span>&]:inline">
|
|
||||||
{item.collapseTitle}
|
|
||||||
</span>
|
|
||||||
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
item.title
|
|
||||||
)}
|
|
||||||
{item.badge && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
{item.badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `child-${level}-${index}`}
|
|
||||||
className={cn('ps-4', !item.collapse && 'relative')}
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>
|
|
||||||
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
|
|
||||||
</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
|
|
||||||
<Link to={item.path || '#'}>
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<div className="flex 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 { cn } from '@crux-kit/utils';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#providers/SettingsProvider';
|
import { useSettings } from '#app/providers/SettingsProvider';
|
||||||
import { SidebarHeader } from './SidebarHeader';
|
import { SidebarHeader } from './SidebarHeader';
|
||||||
import { SidebarMenu } from './SidebarMenu';
|
import { SidebarMenu } from './SidebarMenu';
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'sidebar bg-background lg:border-e lg:border-border lg:fixed lg:top-0 lg:bottom-0 lg:z-20 lg:flex flex-col items-stretch shrink-0',
|
'sidebar bg-background lg:border-border shrink-0 flex-col items-stretch lg:fixed lg:top-0 lg:bottom-0 lg:z-20 lg:flex lg:border-e',
|
||||||
(settings.layouts[LayoutNames.DEFAULT].sidebarTheme === 'dark' ||
|
settings.layouts[LayoutNames.DEFAULT].sidebarTheme === 'dark' && 'dark'
|
||||||
pathname.includes('dark-sidebar')) &&
|
|
||||||
'dark'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SidebarHeader />
|
<SidebarHeader />
|
||||||
|
|||||||
@@ -1,59 +1,34 @@
|
|||||||
import { ChevronFirst } from 'lucide-react';
|
import { ChevronFirst } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
import { cn } from '@crux-kit/utils';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
import { useSettings } from '#app/providers/SettingsProvider';
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { useSettings } from '#providers/SettingsProvider';
|
import { AppLogo } from '../../shared';
|
||||||
|
|
||||||
export function SidebarHeader() {
|
export function SidebarHeader() {
|
||||||
const { settings, storeOption } = useSettings();
|
const { settings, storeOption } = useSettings();
|
||||||
|
|
||||||
|
const collapsed = settings.layouts[LayoutNames.DEFAULT].sidebarCollapse;
|
||||||
|
|
||||||
const handleToggleClick = () => {
|
const handleToggleClick = () => {
|
||||||
storeOption(
|
storeOption(`layouts.${LayoutNames.DEFAULT}.sidebarCollapse`, !collapsed);
|
||||||
`layouts.${LayoutNames.DEFAULT}.sidebarCollapse`,
|
|
||||||
!settings.layouts[LayoutNames.DEFAULT].sidebarCollapse
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar-header hidden lg:flex items-center relative justify-between px-3 lg:px-6 shrink-0">
|
<div className="sidebar-header relative hidden shrink-0 items-center px-5 lg:flex">
|
||||||
<Link to="/">
|
<AppLogo className="size-9 max-w-none" />
|
||||||
<div className="dark:hidden">
|
<span className="default-logo ml-2 text-2xl font-bold tracking-widest whitespace-nowrap">
|
||||||
{/* <img
|
C.R.U.X
|
||||||
src={toAbsoluteUrl('/media/app/default-logo.svg')}
|
</span>
|
||||||
className="default-logo h-[22px] max-w-none"
|
|
||||||
alt="Default Logo"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo.svg')}
|
|
||||||
className="small-logo h-[22px] max-w-none"
|
|
||||||
alt="Mini Logo"
|
|
||||||
/> */}
|
|
||||||
</div>
|
|
||||||
<div className="hidden dark:block">
|
|
||||||
{/* <img
|
|
||||||
src={toAbsoluteUrl('/media/app/default-logo-dark.svg')}
|
|
||||||
className="default-logo h-[22px] max-w-none"
|
|
||||||
alt="Default Dark Logo"
|
|
||||||
/> */}
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo.svg')}
|
|
||||||
className="small-logo h-5.5 max-w-none"
|
|
||||||
alt="Mini Logo"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleToggleClick}
|
onClick={handleToggleClick}
|
||||||
size="sm"
|
size="sm"
|
||||||
mode="icon"
|
mode="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-7 absolute start-full top-2/4 rtl:translate-x-2/4 -translate-x-2/4 -translate-y-2/4',
|
'absolute inset-s-full top-2/4 size-7 -translate-x-2/4 -translate-y-2/4 rtl:translate-x-2/4',
|
||||||
settings.layouts[LayoutNames.DEFAULT].sidebarCollapse
|
collapsed ? 'ltr:rotate-180' : 'rtl:rotate-180'
|
||||||
? 'ltr:rotate-180'
|
|
||||||
: 'rtl:rotate-180'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChevronFirst className="size-4!" />
|
<ChevronFirst className="size-4!" />
|
||||||
|
|||||||
@@ -1,180 +1,22 @@
|
|||||||
'use client';
|
import { SidebarAccordionMenu } from '../../shared';
|
||||||
|
|
||||||
import { JSX, useCallback } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuConfig, MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
|
|
||||||
import { Badge } from '#shared/ui/Badge';
|
|
||||||
|
|
||||||
export function SidebarMenu() {
|
export function SidebarMenu() {
|
||||||
const { pathname } = useLocation();
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
|
|
||||||
// Memoize matchPath to prevent unnecessary re-renders
|
|
||||||
const matchPath = useCallback(
|
|
||||||
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
|
|
||||||
[pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Global classNames for consistent styling
|
|
||||||
const classNames: AccordionMenuClassNames = {
|
|
||||||
root: 'lg:ps-1 space-y-3',
|
|
||||||
group: 'gap-px',
|
|
||||||
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
|
||||||
separator: '',
|
|
||||||
item: 'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
|
||||||
sub: '',
|
|
||||||
subTrigger:
|
|
||||||
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
|
||||||
subContent: 'py-0',
|
|
||||||
indicator: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenu = (items: MenuConfig): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (item.heading) {
|
|
||||||
return buildMenuHeading(item, index);
|
|
||||||
} else if (item.disabled) {
|
|
||||||
return buildMenuItemRootDisabled(item, index);
|
|
||||||
} else {
|
|
||||||
return buildMenuItemRoot(item, index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `root-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-sm font-medium">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `root-${index}`}
|
|
||||||
className="ps-6"
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
|
|
||||||
<Link to={item.path || '#'} className="flex 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 (
|
return (
|
||||||
<div className="kt-scrollable-y-hover flex grow shrink-0 py-5 px-5 lg:max-h-[calc(100vh-5.5rem)]">
|
<div className="kt-scrollable-y-hover flex shrink-0 grow px-5 py-5 lg:max-h-[calc(100vh-5.5rem)]">
|
||||||
<AccordionMenu
|
<SidebarAccordionMenu
|
||||||
selectedValue={pathname}
|
classNames={{
|
||||||
matchPath={matchPath}
|
root: 'lg:ps-1 space-y-3',
|
||||||
type="single"
|
group: 'gap-px',
|
||||||
collapsible
|
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
||||||
classNames={classNames}
|
separator: '',
|
||||||
>
|
item: 'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
||||||
{buildMenu(menuConfig)}
|
sub: '',
|
||||||
</AccordionMenu>
|
subTrigger:
|
||||||
|
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
||||||
|
subContent: 'py-0',
|
||||||
|
indicator: ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
import { Fragment, ReactNode } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
|
|
||||||
export interface ToolbarHeadingProps {
|
|
||||||
title?: string | ReactNode;
|
|
||||||
description?: string | ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toolbar({ children }: { children?: ReactNode }) {
|
|
||||||
return <div className="flex flex-wrap items-center justify-between gap-5 pb-7.5">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarActions({ children }: { children?: ReactNode }) {
|
|
||||||
return <div className="flex items-center gap-2.5">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarBreadcrumbs() {
|
|
||||||
const config = useMenuConfig();
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getBreadcrumb, isActive } = useMenu(pathname);
|
|
||||||
const items: MenuItem[] = getBreadcrumb(config);
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="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 { Outlet } from 'react-router-dom';
|
||||||
import { useIsMobile } from '@crux-kit/hooks';
|
import { useIsMobile } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#providers/SettingsProvider';
|
import { useSettings } from '#app/providers/SettingsProvider';
|
||||||
import { Footer } from './components/Footer';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
|
|
||||||
export function DefaultLayout() {
|
export function DefaultLayout() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { settings, setOption } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
useLayoutSettings(LayoutNames.DEFAULT);
|
||||||
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
|
|
||||||
|
const sidebarCollapse = settings.layouts[LayoutNames.DEFAULT].sidebarCollapse;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bodyClass = document.body.classList;
|
const bodyClass = document.body.classList;
|
||||||
|
|
||||||
if (settings.layouts[LayoutNames.DEFAULT].sidebarCollapse) {
|
if (sidebarCollapse) {
|
||||||
bodyClass.add('sidebar-collapse');
|
bodyClass.add('sidebar-collapse');
|
||||||
} else {
|
} else {
|
||||||
bodyClass.remove('sidebar-collapse');
|
bodyClass.remove('sidebar-collapse');
|
||||||
}
|
}
|
||||||
}, [settings]); // Runs only on settings update
|
}, [sidebarCollapse]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Set current layout
|
|
||||||
setOption('layout', LayoutNames.DEFAULT);
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bodyClass = document.body.classList;
|
const bodyClass = document.body.classList;
|
||||||
|
|
||||||
// Add a class to the body element
|
|
||||||
bodyClass.add(LayoutNames.DEFAULT);
|
bodyClass.add(LayoutNames.DEFAULT);
|
||||||
bodyClass.add('sidebar-fixed');
|
bodyClass.add('sidebar-fixed');
|
||||||
bodyClass.add('header-fixed');
|
bodyClass.add('header-fixed');
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
bodyClass.add('layout-initialized');
|
bodyClass.add('layout-initialized');
|
||||||
}, 1000); // 1000 milliseconds
|
}, 1000);
|
||||||
|
|
||||||
// Remove the class when the component is unmounted
|
|
||||||
return () => {
|
return () => {
|
||||||
bodyClass.remove(LayoutNames.DEFAULT);
|
bodyClass.remove(LayoutNames.DEFAULT);
|
||||||
bodyClass.remove('sidebar-fixed');
|
bodyClass.remove('sidebar-fixed');
|
||||||
@@ -50,7 +48,7 @@ export function DefaultLayout() {
|
|||||||
bodyClass.remove('layout-initialized');
|
bodyClass.remove('layout-initialized');
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, []); // Runs only once on mount
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -60,10 +58,15 @@ export function DefaultLayout() {
|
|||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="grow pt-5" role="content">
|
<main className="grow pt-5" role="content">
|
||||||
|
{showToolbar && (
|
||||||
|
<Toolbar>
|
||||||
|
<ToolbarHeading />
|
||||||
|
</Toolbar>
|
||||||
|
)}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
{showFooter && <AppFooter />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Horizontal Menu layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 2](https://keenthemes.com/metronic/tailwind/react/demo2)
|
||||||
|
|
||||||
|
A header carrying the logo and the account menu, with the application menu on a horizontal menubar
|
||||||
|
underneath it. The header becomes sticky and shrinks once the page is scrolled past
|
||||||
|
`layouts.horizontal-menu-layout.headerStickyOffset`.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| ----------------------- | --------------------------------------------- |
|
||||||
|
| Navigation menubar | `NavMenubar` from `../shared` |
|
||||||
|
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Header`, `HeaderLogo`, `Navbar`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Below `lg` the menubar is replaced by an accordion in a sheet, since a horizontal menubar cannot
|
||||||
|
hold the full menu on a narrow viewport.
|
||||||
|
- The `data-sticky-header` body attribute is removed on unmount so the next layout starts clean.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { generalSettings } from '#app/config/general';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-col 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} ©</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 { useScrollPosition } from '@crux-kit/hooks';
|
||||||
import { cn } from '@crux-kit/utils';
|
import { cn } from '@crux-kit/utils';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
|
import { useSettings } from '#app/providers/SettingsProvider';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
import { useSettings } from '#providers/SettingsProvider';
|
import { UserAvatarMenu } from '../../shared';
|
||||||
import { HeaderLogo } from './HeaderLogo';
|
import { HeaderLogo } from './HeaderLogo';
|
||||||
import { HeaderTopbar } from './HeaderTopbar';
|
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const scrollPosition = useScrollPosition();
|
const scrollPosition = useScrollPosition();
|
||||||
const [headerStickyOn, setHeaderStickyOn] = useState(false);
|
const [headerStickyOn, setHeaderStickyOn] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const { headerSticky, headerStickyOffset } = settings.layouts[LayoutNames.HORIZONTAL_MENU];
|
||||||
const isSticky =
|
|
||||||
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
|
|
||||||
setHeaderStickyOn(isSticky);
|
|
||||||
}, [scrollPosition, settings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (headerStickyOn === true) {
|
setHeaderStickyOn(scrollPosition > headerStickyOffset);
|
||||||
|
}, [scrollPosition, headerStickyOffset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (headerStickyOn) {
|
||||||
document.body.setAttribute('data-sticky-header', 'on');
|
document.body.setAttribute('data-sticky-header', 'on');
|
||||||
} else {
|
} else {
|
||||||
document.body.removeAttribute('data-sticky-header');
|
document.body.removeAttribute('data-sticky-header');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return () => document.body.removeAttribute('data-sticky-header');
|
||||||
}, [headerStickyOn]);
|
}, [headerStickyOn]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center transition-[height] shrink-0 h-(--header-height) in-data-[sticky-header=on]:pe-(--removed-body-scroll-bar-size,0px)',
|
'flex h-(--header-height) shrink-0 items-center transition-[height] in-data-[sticky-header=on]:pe-(--removed-body-scroll-bar-size,0px)',
|
||||||
settings.layouts[LayoutNames.HORIZONTAL_MENU].headerSticky &&
|
headerSticky &&
|
||||||
headerStickyOn &&
|
headerStickyOn &&
|
||||||
'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 />
|
<HeaderLogo />
|
||||||
<HeaderTopbar />
|
<UserAvatarMenu />
|
||||||
</Container>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,71 +1,41 @@
|
|||||||
// import { useEffect, useState } from 'react';
|
import { Menu } from 'lucide-react';
|
||||||
// import { ChevronDown } from 'lucide-react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
// import { MENU_ROOT } from '#app/menu';
|
import { useIsMobile } from '@crux-kit/hooks';
|
||||||
// import {
|
import { Button } from '#shared/ui/Button';
|
||||||
// DropdownMenu,
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
// DropdownMenuContent,
|
import { AppLogo, AppWordmark, SidebarAccordionMenu } from '../../shared';
|
||||||
// DropdownMenuItem,
|
|
||||||
// DropdownMenuTrigger,
|
|
||||||
// } from '#shared/ui/dropdown_menu';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
|
|
||||||
// import { cn } from '@crux-kit/utils';
|
|
||||||
|
|
||||||
export function HeaderLogo() {
|
export function HeaderLogo() {
|
||||||
// const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
// const [selectedMenuItem, setSelectedMenuItem] = useState(MENU_ROOT[1]);
|
const isMobile = useIsMobile();
|
||||||
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
|
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// MENU_ROOT.forEach((item) => {
|
setIsSheetOpen(false);
|
||||||
// if (item.rootPath && pathname.includes(item.rootPath)) {
|
}, [pathname]);
|
||||||
// setSelectedMenuItem(item);
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// }, [pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 lg:gap-5 2xl:-ms-15">
|
<div className="flex items-center gap-2 lg:gap-5 2xl:-ms-15">
|
||||||
{/* Logo Section */}
|
<AppLogo className="size-10.5" />
|
||||||
<Link to="/" className="shrink-0">
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-circle.svg')}
|
|
||||||
className="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>
|
|
||||||
|
|
||||||
{/* Menu Section */}
|
<AppWordmark className="text-accent-foreground hidden text-base md:block" />
|
||||||
<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>
|
{isMobile && (
|
||||||
<DropdownMenuTrigger className="cursor-pointer text-mono font-medium flex items-center gap-2">
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
{selectedMenuItem.title}
|
<SheetTrigger asChild>
|
||||||
<ChevronDown className="size-4 text-muted-foreground" />
|
<Button variant="ghost" mode="icon">
|
||||||
</DropdownMenuTrigger>
|
<Menu />
|
||||||
<DropdownMenuContent sideOffset={10} side="bottom" align="start">
|
</Button>
|
||||||
{MENU_ROOT.map((item, index) => (
|
</SheetTrigger>
|
||||||
<DropdownMenuItem
|
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
||||||
key={index}
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
asChild
|
<SheetBody className="overflow-y-auto py-5">
|
||||||
className={cn(item === selectedMenuItem && 'bg-accent')}
|
<SidebarAccordionMenu />
|
||||||
>
|
</SheetBody>
|
||||||
<Link to={item.path || ''}>
|
</SheetContent>
|
||||||
{item.icon && <item.icon />}
|
</Sheet>
|
||||||
{item.title}
|
)}
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
// import { MessageCircleMore, MessageSquareDot, Search } from 'lucide-react';
|
|
||||||
// import { Button } from '#shared/ui/button';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
// import { SearchDialog } from '#shared/partials/dialogs/search/search-dialog';
|
|
||||||
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
|
|
||||||
// import { NotificationsSheet } from '#shared/partials/topbar/notifications-sheet';
|
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
|
||||||
|
|
||||||
export function HeaderTopbar() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* <SearchDialog
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" shape="circle" className="size-9">
|
|
||||||
<Search className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ChatSheet
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" shape="circle" className="size-9">
|
|
||||||
<MessageCircleMore className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<NotificationsSheet
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" size="sm" shape="circle" className="size-9">
|
|
||||||
<MessageSquareDot className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
</div>
|
|
||||||
<UserDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<img
|
|
||||||
className="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 { Container } from '#shared/widgets/common/Container';
|
||||||
import { NavbarLinks } from './NavbarLinks';
|
import { NavMenubar } from '../../shared';
|
||||||
import { NavbarMenu } from './NavbarMenu';
|
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border pb-5 lg:pb-0 mb-5 lg:mb-10">
|
<div className="border-border mb-5 border-b pb-5 lg:mb-10 lg:pb-0">
|
||||||
<Container className="flex flex-wrap justify-between items-center gap-2">
|
<Container className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<NavbarMenu />
|
<NavMenubar />
|
||||||
<NavbarLinks />
|
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
export function NavbarLinks() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center 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
|
// DEMO 2
|
||||||
// https://keenthemes.com/metronic/tailwind/react/demo2
|
// https://keenthemes.com/metronic/tailwind/react/demo2
|
||||||
|
|
||||||
import { addDays, format } from 'date-fns';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { CalendarDays } from 'lucide-react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { DateRange } from 'react-day-picker';
|
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
|
||||||
import { useBodyClass } from '@crux-kit/hooks';
|
import { useBodyClass } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { Button } from '#shared/ui/Button';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
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 { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Navbar } from './components/Navbar';
|
import { Navbar } from './components/Navbar';
|
||||||
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
|
|
||||||
|
|
||||||
export function HorizontalMenuLayout() {
|
export function HorizontalMenuLayout() {
|
||||||
const { pathname } = useLocation();
|
useLayoutSettings(LayoutNames.HORIZONTAL_MENU);
|
||||||
const { setOption } = useSettings();
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Set current layout
|
|
||||||
setOption('layout', LayoutNames.HORIZONTAL_MENU);
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
useBodyClass(`
|
useBodyClass(`
|
||||||
[--header-height-default:100px]
|
[--header-height-default:100px]
|
||||||
data-[sticky-header=on]:[--header-height:60px]
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height-default)">
|
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height-default)">
|
||||||
@@ -45,45 +26,15 @@ export function HorizontalMenuLayout() {
|
|||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className="grow" role="content">
|
<main className="grow" role="content">
|
||||||
{!pathname.includes('/public-profile/') && (
|
{showToolbar && (
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<ToolbarHeading />
|
<ToolbarHeading />
|
||||||
<ToolbarActions>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button mode="input" variant="outline">
|
|
||||||
<CalendarDays />
|
|
||||||
{date?.from ? (
|
|
||||||
date.to ? (
|
|
||||||
<>
|
|
||||||
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
format(date.from, 'LLL dd, y')
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span>Pick a date range</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="end">
|
|
||||||
<Calendar
|
|
||||||
initialFocus
|
|
||||||
mode="range"
|
|
||||||
defaultMonth={date?.from}
|
|
||||||
selected={date}
|
|
||||||
onSelect={setDate}
|
|
||||||
numberOfMonths={2}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</ToolbarActions>
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
)}
|
)}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
{showFooter && <AppFooter />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Compact Sidebar layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 3](https://keenthemes.com/metronic/tailwind/react/demo3)
|
||||||
|
|
||||||
|
A narrow icon rail listing the top-level menu sections, with the selected section's children on a
|
||||||
|
horizontal navbar above the content card.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| --------------------------- | ------------------------------------------------------------ |
|
||||||
|
| Section chooser (icon rail) | `useAppMenu` + `firstMenuPath` from `../shared` |
|
||||||
|
| Section navbar | `NavMenubar` from `../shared`, fed by `useActiveMenuSection` |
|
||||||
|
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Header`, `HeaderLogo`, `Sidebar`, `SidebarMenu`, `Navbar`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- A section with no children renders no navbar, so a single-page section such as the dashboard fills
|
||||||
|
the card.
|
||||||
|
- This layout sets `container` to `fluid`; the setting is per-layout, so other layouts keep theirs.
|
||||||
|
- Body variables: `--header-height: 58px`, `--sidebar-width: 58px`, `--navbar-height: 56px`.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { generalSettings } from '#app/config/general';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-col 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} ©</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 { Container } from '#shared/widgets/common/Container';
|
||||||
|
import { UserAvatarMenu } from '../../shared';
|
||||||
import { HeaderLogo } from './HeaderLogo';
|
import { HeaderLogo } from './HeaderLogo';
|
||||||
import { HeaderTopbar } from './HeaderTopbar';
|
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center fixed z-10 top-0 left-0 right-0 shrink-0 h-(--header-height) bg-muted">
|
<header className="bg-muted fixed top-0 right-0 left-0 z-10 flex h-(--header-height) shrink-0 items-center">
|
||||||
<Container className="flex justify-between items-stretch px-5 lg:ps-0 lg:gap-4">
|
<Container className="flex items-stretch justify-between px-5 lg:gap-4 lg:ps-0">
|
||||||
<HeaderLogo />
|
<HeaderLogo />
|
||||||
<HeaderTopbar />
|
<UserAvatarMenu />
|
||||||
</Container>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,87 +1,39 @@
|
|||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useEffect, useState } from 'react';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
import { useLocation } from 'react-router-dom';
|
||||||
// import { cn } from '@crux-kit/utils';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
// import {
|
|
||||||
// DropdownMenu,
|
|
||||||
// DropdownMenuContent,
|
|
||||||
// DropdownMenuItem,
|
|
||||||
// DropdownMenuTrigger,
|
|
||||||
// } from '#shared/ui/dropdown_menu';
|
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { SidebarMenu } from './SidebarMenu';
|
import { AppLogo, AppWordmark, SidebarAccordionMenu } from '../../shared';
|
||||||
|
|
||||||
export function HeaderLogo() {
|
export function HeaderLogo() {
|
||||||
// const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
// const [selectedMenuItem, setSelectedMenuItem] = useState(MENU_ROOT[1]);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
|
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// MENU_ROOT.forEach((item) => {
|
setIsSheetOpen(false);
|
||||||
// if (item.rootPath && pathname.includes(item.rootPath)) {
|
}, [pathname]);
|
||||||
// setSelectedMenuItem(item);
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// }, [pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
{/* Logo */}
|
<div className="flex shrink-0 items-center justify-center lg:w-(--sidebar-width)">
|
||||||
<div className="flex items-center justify-center lg:w-(--sidebar-width) shrink-0">
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
<Sheet>
|
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant="ghost" mode="icon" className="-ms-2 lg:hidden">
|
<Button variant="ghost" mode="icon" className="-ms-2 lg:hidden">
|
||||||
<Menu className="size-4!" />
|
<Menu className="size-4!" />
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent className="p-0 gap-0 w-(--sidebar-width)" side="left" close={false}>
|
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
||||||
<SheetHeader className="p-0 space-y-0" />
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
<SheetBody className="p-0 overflow-y-auto">
|
<SheetBody className="overflow-y-auto py-5">
|
||||||
<SidebarMenu />
|
<SidebarAccordionMenu />
|
||||||
</SheetBody>
|
</SheetBody>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<Link to="/" className="mx-1">
|
<AppLogo className="size-6" linkClassName="mx-1" />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-primary.svg')}
|
|
||||||
className="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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu Section */}
|
<AppWordmark className="text-accent-foreground hidden text-base md:block" />
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h3 className="text-accent-foreground 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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
// import { LayoutGrid, MessageCircleMore, MessageSquareDot, Search } from 'lucide-react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
// import { SearchDialog } from '#shared/partials/dialogs/search/search-dialog';
|
|
||||||
// import { AppsDropdownMenu } from '#shared/partials/topbar/apps-dropdown-menu';
|
|
||||||
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
|
|
||||||
// import { NotificationsSheet } from '#shared/partials/topbar/notifications-sheet';
|
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
|
||||||
|
|
||||||
export function HeaderTopbar() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 lg:gap-3.5">
|
|
||||||
<Button asChild className="hidden md:inline-flex">
|
|
||||||
<Link to="account/home/get-started">Get Started</Link>
|
|
||||||
</Button>
|
|
||||||
{/* <SearchDialog
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" shape="circle" className="hover:[&_svg]:text-primary">
|
|
||||||
<Search className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ChatSheet
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" shape="circle" className="hover:[&_svg]:text-primary">
|
|
||||||
<MessageCircleMore className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<AppsDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" shape="circle" className="hover:[&_svg]:text-primary">
|
|
||||||
<LayoutGrid className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<NotificationsSheet
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" shape="circle" className="hover:[&_svg]:text-primary">
|
|
||||||
<MessageSquareDot className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
<UserDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<img
|
|
||||||
className="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 { Container } from '#shared/widgets/common/Container';
|
||||||
import { NavbarLinks } from './NavbarLinks';
|
import { NavMenubar, useActiveMenuSection } from '../../shared';
|
||||||
import { NavbarMenu } from './NavbarMenu';
|
|
||||||
|
|
||||||
|
/** The children of the section selected in the compact sidebar. */
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
|
const section = useActiveMenuSection();
|
||||||
|
const items = section?.children ?? [];
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="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="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="rounded-t-xl border border-border bg-background flex items-stretch grow">
|
<div className="border-border bg-background flex grow items-stretch rounded-t-xl border">
|
||||||
<Container className="flex justify-between items-stretch gap-5">
|
<Container className="flex items-stretch justify-between gap-5">
|
||||||
<NavbarMenu />
|
<NavMenubar items={items} />
|
||||||
<NavbarLinks />
|
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { addDays, format } from 'date-fns';
|
|
||||||
import { CalendarDays } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { DateRange } from 'react-day-picker';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
import { Calendar } from '#shared/ui/Calendar';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '#shared/ui/Popover';
|
|
||||||
|
|
||||||
export function NavbarLinks() {
|
|
||||||
const [date, setDate] = useState<DateRange | undefined>({
|
|
||||||
from: new Date(2025, 0, 20),
|
|
||||||
to: addDays(new Date(2025, 0, 20), 20)
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button id="date" variant="outline" mode="input">
|
|
||||||
<CalendarDays />
|
|
||||||
{date?.from ? (
|
|
||||||
date.to ? (
|
|
||||||
<span>
|
|
||||||
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
format(date.from, 'LLL dd, y')
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span>Pick a date range</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="end">
|
|
||||||
<Calendar
|
|
||||||
mode="range"
|
|
||||||
defaultMonth={date?.from}
|
|
||||||
selected={date}
|
|
||||||
onSelect={setDate}
|
|
||||||
numberOfMonths={2}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { ChevronDown } from 'lucide-react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuConfig } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { Menubar, MenubarContent, MenubarItem, MenubarMenu, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger } from '#shared/ui/MenuBar';
|
|
||||||
|
|
||||||
export function NavbarMenu() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
|
|
||||||
let navbarMenu;
|
|
||||||
|
|
||||||
if (pathname.includes('/public-profile/')) {
|
|
||||||
navbarMenu = menuConfig?.[2];
|
|
||||||
} else if (pathname.includes('/network/')) {
|
|
||||||
navbarMenu = menuConfig?.[4];
|
|
||||||
} else if (pathname.includes('/store-client/')) {
|
|
||||||
navbarMenu = menuConfig?.[0];
|
|
||||||
} else if (pathname.includes('/authentication/')) {
|
|
||||||
navbarMenu = menuConfig?.[5];
|
|
||||||
} else {
|
|
||||||
navbarMenu = menuConfig?.[3];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isActive, hasActiveChild } = useMenu(pathname);
|
|
||||||
|
|
||||||
const buildMenu = (items: MenuConfig) => {
|
|
||||||
return items.map((item, index) => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<MenubarMenu key={index}>
|
|
||||||
<MenubarTrigger
|
|
||||||
className={cn(
|
|
||||||
'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() {
|
export function Sidebar() {
|
||||||
return (
|
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="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 grow shrink-0">
|
<div className="flex shrink-0 grow">
|
||||||
<div className="kt-scrollable-y-auto grow gap-2.5 shrink-0 flex items-center flex-col max-h-[calc(100vh-3rem)]">
|
<div className="kt-scrollable-y-auto flex max-h-[calc(100vh-3rem)] shrink-0 grow flex-col items-center gap-2.5">
|
||||||
<SidebarMenu />
|
<SidebarMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,99 +1,52 @@
|
|||||||
import { BarChart3, Bell, CheckSquare, Code, HelpCircle, MessageSquare, Settings, Shield, UserCircle, Users } from 'lucide-react';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
import { cn } from '@crux-kit/utils';
|
||||||
|
import { useMenu } from '#shared/hooks/useMenu';
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip';
|
||||||
|
import { firstMenuPath, useAppMenu } from '../../shared';
|
||||||
|
|
||||||
export interface Item {
|
/**
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
* The section chooser: one icon per top-level menu entry. Its children are shown by the navbar, so
|
||||||
path: string;
|
* this rail never needs to expand.
|
||||||
title: string;
|
*/
|
||||||
newTab?: boolean;
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarMenu() {
|
export function SidebarMenu() {
|
||||||
const items: Item[] = [
|
const menuConfig = useAppMenu();
|
||||||
{
|
const { pathname } = useLocation();
|
||||||
icon: BarChart3,
|
const { isItemActive } = useMenu(pathname);
|
||||||
path: '/',
|
|
||||||
title: 'Dashboard'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: UserCircle,
|
|
||||||
path: '/public-profile/profiles/default',
|
|
||||||
title: 'Profile'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Settings,
|
|
||||||
path: '/account/home/get-started',
|
|
||||||
title: 'Account'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Users,
|
|
||||||
path: '/network/get-started',
|
|
||||||
title: 'Network',
|
|
||||||
active: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Shield,
|
|
||||||
path: '/account/billing/plans',
|
|
||||||
title: 'Plans'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: MessageSquare,
|
|
||||||
path: '/account/security/security-log',
|
|
||||||
title: 'Security Logs'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Bell,
|
|
||||||
path: '/account/notifications',
|
|
||||||
title: 'Notifications'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: CheckSquare,
|
|
||||||
path: '/account/members/roles',
|
|
||||||
title: 'ACL'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Code,
|
|
||||||
path: '/account/api-keys',
|
|
||||||
title: 'API Keys'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: HelpCircle,
|
|
||||||
path: 'https://docs.keenthemes.com/metronic-vite',
|
|
||||||
title: 'Docs'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex flex-col grow items-center py-3.5 lg:py-0 gap-2.5">
|
<div className="flex grow flex-col items-center gap-2.5 py-3.5 lg:py-0">
|
||||||
{items.map((item, index) => (
|
{menuConfig.map((item, index) => {
|
||||||
<Tooltip key={index}>
|
const path = firstMenuPath(item);
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
if (!path) {
|
||||||
variant="ghost"
|
return null;
|
||||||
shape="circle"
|
}
|
||||||
mode="icon"
|
|
||||||
{...(item.active ? { 'data-state': 'open' } : {})}
|
return (
|
||||||
className={cn(
|
<Tooltip key={index}>
|
||||||
'data-[state=open]:bg-background data-[state=open]:border data-[state=open]:border-input data-[state=open]:text-primary',
|
<TooltipTrigger asChild>
|
||||||
'hover:bg-background hover:border hover:border-input hover:text-primary'
|
<Button
|
||||||
)}
|
asChild
|
||||||
>
|
variant="ghost"
|
||||||
<Link
|
shape="circle"
|
||||||
to={item.path || ''}
|
mode="icon"
|
||||||
{...(item.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
{...(isItemActive(item) ? { 'data-state': 'open' } : {})}
|
||||||
|
className={cn(
|
||||||
|
'data-[state=open]:bg-background data-[state=open]:border-input data-[state=open]:text-primary data-[state=open]:border',
|
||||||
|
'hover:bg-background hover:border-input hover:text-primary hover:border'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="size-4.5!" />
|
<Link to={path} aria-label={item.title}>
|
||||||
</Link>
|
{item.icon && <item.icon className="size-4.5!" />}
|
||||||
</Button>
|
</Link>
|
||||||
</TooltipTrigger>
|
</Button>
|
||||||
<TooltipContent side="right">{item.title}</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent side="right">{item.title}</TooltipContent>
|
||||||
))}
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
import { Fragment, ReactNode } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export interface ToolbarHeadingProps {
|
|
||||||
title?: string | ReactNode;
|
|
||||||
description?: string | ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toolbar({ children }: { children?: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-5 lg:mb-10">
|
|
||||||
<Container className="flex 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
|
// DEMO 3
|
||||||
// https://keenthemes.com/metronic/tailwind/react/demo3
|
// https://keenthemes.com/metronic/tailwind/react/demo3
|
||||||
|
|
||||||
import { Download } from 'lucide-react';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
|
||||||
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { Button } from '#shared/ui/Button';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
import { useSettings } from '#providers/SettingsProvider';
|
|
||||||
import { Footer } from './components/Footer';
|
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Navbar } from './components/Navbar';
|
import { Navbar } from './components/Navbar';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
|
|
||||||
|
|
||||||
export function CompactSidebarLayout() {
|
export function CompactSidebarLayout() {
|
||||||
const { pathname } = useLocation();
|
useLayoutSettings(LayoutNames.COMPACT_SIDEBAR);
|
||||||
const { setOption } = useSettings();
|
const isMobile = useIsMobile();
|
||||||
const isMobileMode = useIsMobile();
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
|
|
||||||
useBodyClass(`
|
useBodyClass(`
|
||||||
[--header-height:58px]
|
[--header-height:58px]
|
||||||
[--sidebar-width:58px]
|
[--sidebar-width:58px]
|
||||||
[--navbar-height:56px]
|
[--navbar-height:56px]
|
||||||
lg:overflow-hidden
|
lg:overflow-hidden
|
||||||
bg-muted!
|
bg-muted!
|
||||||
`);
|
`);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOption('layout', LayoutNames.COMPACT_SIDEBAR);
|
|
||||||
setOption('container', 'fluid');
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex grow">
|
<div className="flex grow">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row grow pt-(--header-height)">
|
<div className="flex grow flex-col pt-(--header-height) lg:flex-row">
|
||||||
{!isMobileMode && <Sidebar />}
|
{!isMobile && <Sidebar />}
|
||||||
|
|
||||||
<Navbar />
|
<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="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="flex flex-col grow kt-scrollable-y lg:[scrollbar-width:auto] pt-7 lg:**:data-[slot=container]:pe-2">
|
<div className="kt-scrollable-y flex grow flex-col pt-7 lg:[scrollbar-width:auto] lg:**:data-[slot=container]:pe-2">
|
||||||
<main className="grow" role="content">
|
<main className="grow" role="content">
|
||||||
{pathname !== '/' && (
|
{showToolbar && (
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<ToolbarHeading />
|
<ToolbarHeading />
|
||||||
<ToolbarActions>
|
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link to={'/account/home/get-started'}>
|
|
||||||
<Download />
|
|
||||||
Export
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</ToolbarActions>
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
)}
|
)}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
{showFooter && <AppFooter />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Two Column Sidebar layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 4](https://keenthemes.com/metronic/tailwind/react/demo4)
|
||||||
|
|
||||||
|
Two sidebar columns: a narrow icon rail choosing the top-level section, and a wider column with that
|
||||||
|
section's children as an accordion. Below `lg` both columns move into a single sheet.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| ----------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| Section chooser | `useAppMenu` + `firstMenuPath` from `../shared` |
|
||||||
|
| Section menu | `SidebarAccordionMenu` from `../shared`, fed by `useActiveMenuSection` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Sidebar`, `SidebarPrimary`, `SidebarSecondary`, `Header`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- When the active section has no children, the secondary column shows the whole menu rather than
|
||||||
|
going blank.
|
||||||
|
- Body variables: `--header-height: 60px`, `--sidebar-width: 290px`.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { generalSettings } from '#app/config/general';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-col 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} ©</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 { Menu } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
|
import { AppLogo } from '../../shared';
|
||||||
import { SidebarPrimary } from './SidebarPrimary';
|
import { SidebarPrimary } from './SidebarPrimary';
|
||||||
import { SidebarSecondary } from './SidebarSecondary';
|
import { SidebarSecondary } from './SidebarSecondary';
|
||||||
|
|
||||||
@@ -12,26 +12,14 @@ export function Header() {
|
|||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
// Close sheet when route changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSheetOpen(false);
|
setIsSheetOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
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)">
|
<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 items-center justify-between flex-wrap gap-3">
|
<Container className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Link to="/">
|
<AppLogo className="size-7.5" />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-gray.svg')}
|
|
||||||
className="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>
|
|
||||||
|
|
||||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
@@ -39,9 +27,9 @@ export function Header() {
|
|||||||
<Menu />
|
<Menu />
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent className="p-0 gap-0 w-(--sidebar-width)" side="left" close={false}>
|
<SheetContent className="w-(--sidebar-width) gap-0 p-0" side="left" close={false}>
|
||||||
<SheetHeader className="p-0 space-y-0" />
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
<SheetBody className="p-0 flex items-stretch shrink-0 overflow-y-auto">
|
<SheetBody className="flex shrink-0 items-stretch overflow-y-auto p-0">
|
||||||
<SidebarPrimary />
|
<SidebarPrimary />
|
||||||
<SidebarSecondary />
|
<SidebarSecondary />
|
||||||
</SheetBody>
|
</SheetBody>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { SidebarSecondary } from './SidebarSecondary';
|
|||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
return (
|
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 />
|
<SidebarPrimary />
|
||||||
<SidebarSecondary />
|
<SidebarSecondary />
|
||||||
</div>
|
</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 { Link, useLocation } from 'react-router-dom';
|
||||||
import { useViewport } from '@crux-kit/hooks';
|
import { cn } from '@crux-kit/utils';
|
||||||
import { cn, getHeight } from '@crux-kit/utils';
|
import { useMenu } from '#shared/hooks/useMenu';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip';
|
||||||
// import { AppsDropdownMenu } from '#shared/partials/topbar/apps-dropdown-menu';
|
import { AppLogo, firstMenuPath, useAppMenu, UserAvatarMenu } from '../../shared';
|
||||||
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
|
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
|
||||||
|
|
||||||
interface MenuItem {
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
tooltip: string;
|
|
||||||
path: string;
|
|
||||||
rootPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
|
||||||
{ icon: BarChart3, tooltip: 'Dashboard', path: '/', rootPath: '/' },
|
|
||||||
{
|
|
||||||
icon: UserCircle,
|
|
||||||
tooltip: 'Profile',
|
|
||||||
path: '/public-profile/profiles/default',
|
|
||||||
rootPath: '/public-profile/'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Settings,
|
|
||||||
tooltip: 'Account',
|
|
||||||
path: '/account/home/get-started',
|
|
||||||
rootPath: '/account/'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Users,
|
|
||||||
tooltip: 'Network',
|
|
||||||
path: '/network/get-started',
|
|
||||||
rootPath: '/network/'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: ShoppingCart,
|
|
||||||
tooltip: 'Store - Client',
|
|
||||||
path: '/store-client/home',
|
|
||||||
rootPath: ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Shield,
|
|
||||||
tooltip: 'Authentication',
|
|
||||||
path: '/authentication/get-started',
|
|
||||||
rootPath: '/authentication/'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: MessageSquare,
|
|
||||||
tooltip: 'Security Logs',
|
|
||||||
path: '/account/security/security-log',
|
|
||||||
rootPath: '/account/'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Bell,
|
|
||||||
tooltip: 'Notifications',
|
|
||||||
path: '/account/notifications',
|
|
||||||
rootPath: ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: CheckSquare,
|
|
||||||
tooltip: 'ACL',
|
|
||||||
path: '/account/members/roles',
|
|
||||||
rootPath: ''
|
|
||||||
},
|
|
||||||
{ icon: Code, tooltip: 'API Keys', path: '/account/api-keys', rootPath: '' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SidebarPrimary() {
|
export function SidebarPrimary() {
|
||||||
const headerRef = useRef<HTMLDivElement>(null);
|
const menuConfig = useAppMenu();
|
||||||
const footerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [scrollableHeight, setScrollableHeight] = useState<number>(0);
|
|
||||||
const [viewportHeight] = useViewport();
|
|
||||||
const scrollableOffset = 80;
|
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
const { isItemActive } = useMenu(pathname);
|
||||||
useEffect(() => {
|
|
||||||
if (headerRef.current && footerRef.current) {
|
|
||||||
const headerHeight = getHeight(headerRef.current);
|
|
||||||
const footerHeight = getHeight(footerRef.current);
|
|
||||||
const availableHeight = viewportHeight - headerHeight - footerHeight - scrollableOffset;
|
|
||||||
setScrollableHeight(availableHeight);
|
|
||||||
} else {
|
|
||||||
setScrollableHeight(viewportHeight);
|
|
||||||
}
|
|
||||||
}, [viewportHeight]);
|
|
||||||
|
|
||||||
const [selectedMenuItem, setSelectedMenuItem] = useState(menuItems[0]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
menuItems.forEach((item) => {
|
|
||||||
if (item.rootPath === pathname || (item.rootPath && pathname.includes(item.rootPath))) {
|
|
||||||
setSelectedMenuItem(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex flex-col items-stretch shrink-0 gap-5 py-5 w-17.5 border-e border-input">
|
<div className="border-input flex w-17.5 shrink-0 flex-col items-stretch gap-5 border-e py-5">
|
||||||
<div ref={headerRef} className="hidden lg:flex items-center justify-center shrink-0">
|
<div className="hidden shrink-0 items-center justify-center lg:flex">
|
||||||
<Link to="/">
|
<AppLogo className="size-7.5" />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-gray.svg')}
|
|
||||||
className="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>
|
</div>
|
||||||
|
|
||||||
<div className="flex grow shrink-0">
|
<div className="kt-scrollable-y-hover flex shrink-0 grow flex-col gap-2.5 ps-4">
|
||||||
<div
|
{menuConfig.map((item, index) => {
|
||||||
className="kt-scrollable-y-hover grow gap-2.5 shrink-0 flex ps-4 flex-col"
|
const path = firstMenuPath(item);
|
||||||
style={{ height: `${scrollableHeight}px` }}
|
|
||||||
>
|
if (!path) {
|
||||||
{menuItems.map((item, index) => (
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<Tooltip key={index}>
|
<Tooltip key={index}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
mode="icon"
|
mode="icon"
|
||||||
{...(item === selectedMenuItem ? { 'data-state': 'open' } : {})}
|
{...(isItemActive(item) ? { 'data-state': 'open' } : {})}
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0 rounded-md size-9',
|
'size-9 shrink-0 rounded-md',
|
||||||
'data-[state=open]:bg-background data-[state=open]:border data-[state=open]:border-input data-[state=open]:text-primary',
|
'data-[state=open]:bg-background data-[state=open]:border-input data-[state=open]:text-primary data-[state=open]:border',
|
||||||
'hover:bg-background hover:border hover:border-input hover:text-primary'
|
'hover:bg-background hover:border-input hover:text-primary hover:border'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Link to={item.path}>
|
<Link to={path} aria-label={item.title}>
|
||||||
<item.icon className="size-4.5!" />
|
{item.icon && <item.icon className="size-4.5!" />}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{item.tooltip}</TooltipContent>
|
<TooltipContent side="right">{item.title}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={footerRef} className="flex flex-col gap-4 items-center shrink-0">
|
<div className="flex shrink-0 flex-col items-center gap-4">
|
||||||
{/* <ChatSheet
|
<UserAvatarMenu />
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
className="size-9 hover:bg-background hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<MessageCircleMore className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<AppsDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
className="size-9 hover:bg-background hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<LayoutGrid className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
<UserDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<img
|
|
||||||
className="size-9 rounded-full border border-border shrink-0 cursor-pointer"
|
|
||||||
src={toAbsoluteUrl('/media/avatars/300-2.png')}
|
|
||||||
alt="User Avatar"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
import { useLocation } from 'react-router';
|
import { SidebarAccordionMenu, useActiveMenuSection } from '../../shared';
|
||||||
import { SidebarMenuDashboard } from './SidebarMenuDashboard';
|
|
||||||
import { SidebarMenuDefault } from './SidebarMenuDefault';
|
|
||||||
|
|
||||||
|
const CLASS_NAMES = {
|
||||||
|
root: 'lg:ps-1 space-y-1',
|
||||||
|
group: 'gap-px',
|
||||||
|
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
||||||
|
separator: '',
|
||||||
|
item: 'h-8 hover:bg-background border-accent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium',
|
||||||
|
sub: '',
|
||||||
|
subTrigger:
|
||||||
|
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-transparent data-[selected=true]:font-medium',
|
||||||
|
subContent: 'ps-3 py-0',
|
||||||
|
indicator: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The children of the section chosen in the primary column. A section without children (a
|
||||||
|
* single-page entry such as the dashboard) falls back to the whole menu, so this column is never
|
||||||
|
* blank.
|
||||||
|
*/
|
||||||
export function SidebarSecondary() {
|
export function SidebarSecondary() {
|
||||||
const { pathname } = useLocation();
|
const section = useActiveMenuSection();
|
||||||
|
const items = section?.children;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grow shrink-0 ps-3.5 kt-scrollable-y-hover max-h-[calc(100vh-2rem)] pe-1 my-5">
|
<div className="kt-scrollable-y-hover my-5 max-h-[calc(100vh-2rem)] shrink-0 grow ps-3.5 pe-1">
|
||||||
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />}
|
<SidebarAccordionMenu classNames={CLASS_NAMES} items={items} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { Fragment, ReactNode } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export interface ToolbarHeadingProps {
|
|
||||||
title?: string | ReactNode;
|
|
||||||
description?: string | ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toolbar({ children }: { children?: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-5">
|
|
||||||
<Container className="flex 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
|
// DEMO 4
|
||||||
// https://keenthemes.com/metronic/tailwind/react/demo4
|
// https://keenthemes.com/metronic/tailwind/react/demo4
|
||||||
|
|
||||||
import { Download } from 'lucide-react';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { Link, Outlet } from 'react-router-dom';
|
|
||||||
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { Button } from '#shared/ui/Button';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
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 { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
|
|
||||||
|
|
||||||
export function TwoColumnSidebarLayout() {
|
export function TwoColumnSidebarLayout() {
|
||||||
const { setOption } = useSettings();
|
useLayoutSettings(LayoutNames.TWO_COLUMN_SIDEBAR);
|
||||||
const isMobileMode = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
|
|
||||||
// Using the custom hook to set multiple CSS variables and class properties
|
|
||||||
useBodyClass(`
|
useBodyClass(`
|
||||||
[--header-height:60px]
|
[--header-height:60px]
|
||||||
[--sidebar-width:290px]
|
[--sidebar-width:290px]
|
||||||
lg:overflow-hidden
|
lg:overflow-hidden
|
||||||
bg-muted!
|
bg-muted!
|
||||||
`);
|
`);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOption('layout', LayoutNames.TWO_COLUMN_SIDEBAR);
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex grow">
|
<div className="flex grow">
|
||||||
{isMobileMode && <Header />}
|
{isMobile && <Header />}
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row grow pt-(--header-height) lg:pt-0">
|
<div className="flex grow flex-col pt-(--header-height) lg:flex-row lg:pt-0">
|
||||||
{!isMobileMode && <Sidebar />}
|
{!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="bg-background border-input m-5 mt-0 flex grow rounded-xl border lg:ms-(--sidebar-width) lg:mt-5">
|
||||||
<div className="flex flex-col grow kt-scrollable-y-auto lg:[--kt-scrollbar-width:auto] pt-5">
|
<div className="kt-scrollable-y-auto flex grow flex-col pt-5 lg:[--kt-scrollbar-width:auto]">
|
||||||
<main className="grow" role="content">
|
<main className="grow" role="content">
|
||||||
<Toolbar>
|
{showToolbar && (
|
||||||
<ToolbarHeading />
|
<Toolbar>
|
||||||
|
<ToolbarHeading />
|
||||||
<ToolbarActions>
|
</Toolbar>
|
||||||
{/* <SearchDialog
|
)}
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
className="hover:bg-primary/10 hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<Search className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<NotificationsSheet
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
className="hover:bg-primary/10 hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<MessageSquareDot className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="hover:bg-background hover:[&_svg]:text-primary hover:text-primary ms-2.5 "
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link to={'/account/home/get-started'}>
|
|
||||||
<Download />
|
|
||||||
Export
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</ToolbarActions>
|
|
||||||
</Toolbar>
|
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
{showFooter && <AppFooter />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Dual Rows Header layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 5](https://keenthemes.com/metronic/tailwind/react/demo5)
|
||||||
|
|
||||||
|
A two-row header — logo and account menu on top, the top-level sections on a menubar below — with
|
||||||
|
the selected section's children in a slim sidebar beside the content.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| ----------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| Section menubar | `NavMenubar` from `../shared` |
|
||||||
|
| Section sidebar | `SidebarAccordionMenu` from `../shared`, fed by `useActiveMenuSection` |
|
||||||
|
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Header`, `HeaderLogo`, `Navbar`, `Sidebar`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- A section with no children renders no sidebar, so the content spans the full width.
|
||||||
|
- This layout sets `container` to `fluid`.
|
||||||
|
- Body variables: `--header-height: 54px`, `--sidebar-width: 200px`.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { generalSettings } from '#app/config/general';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-col 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} ©</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 { useScrollPosition } from '@crux-kit/hooks';
|
||||||
import { cn } from '@crux-kit/utils';
|
import { cn } from '@crux-kit/utils';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
|
import { useSettings } from '#app/providers/SettingsProvider';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
import { useSettings } from '#providers/SettingsProvider';
|
import { UserAvatarMenu } from '../../shared';
|
||||||
import { HeaderLogo } from './HeaderLogo';
|
import { HeaderLogo } from './HeaderLogo';
|
||||||
import { HeaderTopbar } from './HeaderTopbar';
|
|
||||||
|
|
||||||
const Header = () => {
|
export function Header() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const scrollPosition = useScrollPosition();
|
const scrollPosition = useScrollPosition();
|
||||||
const [headerStickyOn, setHeaderStickyOn] = useState(false);
|
const [headerStickyOn, setHeaderStickyOn] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const { headerSticky, headerStickyOffset } = settings.layouts[LayoutNames.DUAL_ROW_HEADER];
|
||||||
const isSticky =
|
|
||||||
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
|
|
||||||
setHeaderStickyOn(isSticky);
|
|
||||||
}, [scrollPosition, settings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (headerStickyOn === true) {
|
setHeaderStickyOn(scrollPosition > headerStickyOffset);
|
||||||
|
}, [scrollPosition, headerStickyOffset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (headerStickyOn) {
|
||||||
document.body.setAttribute('data-sticky-header', 'on');
|
document.body.setAttribute('data-sticky-header', 'on');
|
||||||
} else {
|
} else {
|
||||||
document.body.removeAttribute('data-sticky-header');
|
document.body.removeAttribute('data-sticky-header');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return () => document.body.removeAttribute('data-sticky-header');
|
||||||
}, [headerStickyOn]);
|
}, [headerStickyOn]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center transition-[height] shrink-0 bg-(--header-bg) dark:bg-(--header-bg-dark) h-(--header-height)',
|
'flex h-(--header-height) shrink-0 items-center transition-[height]',
|
||||||
headerStickyOn &&
|
headerSticky &&
|
||||||
'transition-[height] fixed z-10 top-0 start-0 end-0 shadow-xs backdrop-blur-md bg-white/70'
|
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 />
|
<HeaderLogo />
|
||||||
<HeaderTopbar />
|
<UserAvatarMenu />
|
||||||
</Container>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export { Header };
|
|
||||||
|
|||||||
@@ -1,108 +1,25 @@
|
|||||||
import { ChevronDown, Menu, Settings, Shield, UserCircle, Users, type LucideIcon } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useIsMobile } from '@crux-kit/hooks';
|
import { useIsMobile } from '@crux-kit/hooks';
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
|
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { SidebarMenuDashboard } from './SidebarMenuDashboard';
|
import { AppLogo, AppWordmark, SidebarAccordionMenu } from '../../shared';
|
||||||
import { SidebarMenuDefault } from './SidebarMenuDefault';
|
|
||||||
|
|
||||||
interface HeaderLogoTeam {
|
|
||||||
title: string;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
urlPartial: string;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
type HeaderLogoTeams = Array<HeaderLogoTeam>;
|
|
||||||
|
|
||||||
interface HeaderLogoItem {
|
|
||||||
title: string;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
}
|
|
||||||
type HeaderLogoItems = Array<HeaderLogoItem>;
|
|
||||||
|
|
||||||
interface HeaderLogoStaging {
|
|
||||||
title: string;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
}
|
|
||||||
type HeaderLogoStagings = Array<HeaderLogoStaging>;
|
|
||||||
|
|
||||||
export function HeaderLogo() {
|
export function HeaderLogo() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const teams: HeaderLogoTeams = [
|
|
||||||
{
|
|
||||||
title: 'MetronicTeam',
|
|
||||||
icon: UserCircle,
|
|
||||||
urlPartial: '/public-profile/',
|
|
||||||
path: '/public-profile/profiles/default'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'KeenTeam',
|
|
||||||
icon: Settings,
|
|
||||||
urlPartial: '/account/',
|
|
||||||
path: '/'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const items: HeaderLogoItems = [
|
|
||||||
{
|
|
||||||
title: 'Campaign',
|
|
||||||
icon: UserCircle
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Fall Winter 2024 ',
|
|
||||||
icon: Settings
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Barberry Autmn 24',
|
|
||||||
icon: Users
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'PF24 Advertising',
|
|
||||||
icon: Shield
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const stagings: HeaderLogoStagings = [
|
|
||||||
{
|
|
||||||
title: 'Staging',
|
|
||||||
icon: UserCircle
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Account',
|
|
||||||
icon: Settings
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const [selectedTeam, setSelectedTeam] = useState(teams[0]);
|
|
||||||
const [selectedItem, setSelectedItem] = useState(items[0]);
|
|
||||||
const [selectedStaging, setSelectedStaging] = useState(stagings[0]);
|
|
||||||
|
|
||||||
// Close sheet when route changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSheetOpen(false);
|
setIsSheetOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 lg:gap-5">
|
<div className="flex items-center gap-1.5 lg:gap-5">
|
||||||
<Link to="/">
|
<AppLogo className="size-8.5" />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-circle.svg')}
|
<AppWordmark className="text-accent-foreground hidden text-base md:block" />
|
||||||
className="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>
|
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
@@ -111,91 +28,14 @@ export function HeaderLogo() {
|
|||||||
<Menu />
|
<Menu />
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent className="p-0 gap-0 w-62.5" side="left" close={false}>
|
<SheetContent className="w-62.5 gap-0 p-0" side="left" close={false}>
|
||||||
<SheetHeader className="p-0 space-y-0" />
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
<SheetBody className="p-3 overflow-y-auto">
|
<SheetBody className="overflow-y-auto py-5">
|
||||||
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />}
|
<SidebarAccordionMenu />
|
||||||
</SheetBody>
|
</SheetBody>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</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>
|
</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 { Container } from '#shared/widgets/common/Container';
|
||||||
import { NavbarMenu } from './NavbarMenu';
|
import { NavMenubar } from '../../shared';
|
||||||
|
|
||||||
|
/** The top-level sections, on the header's second row. */
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-bg-background border-y border-border mb-5 lg:mb-8">
|
<div className="bg-bg-background border-border mb-5 border-y lg:mb-8">
|
||||||
<Container className="flex flex-wrap justify-between items-center gap-2">
|
<Container className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<NavbarMenu />
|
<NavMenubar />
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
|
|
||||||
export function NavbarMenu() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
const items = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
title: 'Dashboards',
|
|
||||||
path: '/',
|
|
||||||
partial: '/'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Public Profiles',
|
|
||||||
path: '/public-profile/profiles/default',
|
|
||||||
partial: '/public-profile'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Account Settings',
|
|
||||||
path: '/account/home/get-started',
|
|
||||||
partial: '/account'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Network',
|
|
||||||
path: '/network/get-started',
|
|
||||||
partial: '/network'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Store - Client',
|
|
||||||
path: '/store-client/home',
|
|
||||||
partial: '/store-client'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Authentication',
|
|
||||||
path: '/authentication/get-started',
|
|
||||||
partial: '/authentication'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[] // Empty dependency array since the data is static
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedItem, setSelectedItem] = useState(items[0]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
items.forEach((item) => {
|
|
||||||
if (item.partial !== '' && pathname.startsWith(item.partial)) {
|
|
||||||
setSelectedItem(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [items, pathname]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid">
|
|
||||||
<div className="kt-scrollable-x-auto">
|
|
||||||
<div className="flex 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 { SidebarAccordionMenu, useActiveMenuSection } from '../../shared';
|
||||||
import { SidebarMenuDashboard } from './SidebarMenuDashboard';
|
|
||||||
import { SidebarMenuDefault } from './SidebarMenuDefault';
|
|
||||||
|
|
||||||
|
const CLASS_NAMES = {
|
||||||
|
root: 'space-y-1 py-5',
|
||||||
|
group: 'gap-px',
|
||||||
|
label: 'uppercase text-xs font-medium text-muted-foreground/80 pt-6 mb-2 pb-0',
|
||||||
|
separator: '',
|
||||||
|
item: 'h-8 hover:bg-background border-accent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium',
|
||||||
|
sub: '',
|
||||||
|
subTrigger:
|
||||||
|
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-transparent data-[selected=true]:font-medium',
|
||||||
|
subContent: 'ps-3 py-0',
|
||||||
|
indicator: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The children of the section selected on the navbar. */
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { pathname } = useLocation();
|
const section = useActiveMenuSection();
|
||||||
|
const items = section?.children;
|
||||||
|
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-stretch shrink-0 px-2 w-(--sidebar-width) bg-background">
|
<div className="bg-background flex w-(--sidebar-width) shrink-0 items-stretch px-2">
|
||||||
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />}
|
<SidebarAccordionMenu classNames={CLASS_NAMES} items={items} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
import { Badge, ChevronDown, FileText, LucideIcon, Settings, SquareCode, UserCircle } from 'lucide-react';
|
|
||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel } from '#shared/ui/AccordionMenu';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
|
|
||||||
|
|
||||||
interface DropdownItem {
|
|
||||||
title: string;
|
|
||||||
path: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MenuItem {
|
|
||||||
title: string;
|
|
||||||
path?: string;
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MenuLabel {
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MenuNode = MenuItem | MenuLabel;
|
|
||||||
|
|
||||||
export function SidebarMenuDashboard() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
const dropdownItems = useMemo<DropdownItem[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
title: 'Client API',
|
|
||||||
path: '/account/home/user-profile',
|
|
||||||
icon: SquareCode,
|
|
||||||
active: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Profile',
|
|
||||||
path: '/public-profile/profiles/company',
|
|
||||||
icon: UserCircle
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'My Account',
|
|
||||||
path: '/account/integrations',
|
|
||||||
icon: Settings
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Projects',
|
|
||||||
path: '/public-profile/projects/3-columns',
|
|
||||||
icon: FileText
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Personal info',
|
|
||||||
path: '/public-profile/profiles/creator',
|
|
||||||
icon: Badge
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentDropdownItem = dropdownItems[0];
|
|
||||||
|
|
||||||
const menuItems = useMemo<MenuNode[]>(
|
|
||||||
() => [
|
|
||||||
{ label: 'Configuration' },
|
|
||||||
{ title: 'API Setup', path: '/account/api-keys' },
|
|
||||||
{ title: 'Team Settings', path: '/account/home/settings-sidebar' },
|
|
||||||
{ title: 'Authentication', path: '/authentication/classic/sign-in' },
|
|
||||||
{ title: 'Endpoints Configs', path: '/account/appearance' },
|
|
||||||
{ title: 'Rate Limiting', path: '/public-profile/network' },
|
|
||||||
{ label: 'Security' },
|
|
||||||
{ title: 'Data Encryption', path: '/account/billing/enterprise' },
|
|
||||||
{ title: 'Text', path: '/account/security/overview' },
|
|
||||||
{ title: 'Access Control', path: '/account/security/privacy-settings' },
|
|
||||||
{ label: 'Analytics' },
|
|
||||||
{
|
|
||||||
title: 'Incident Response',
|
|
||||||
path: '/account/security/current-sessions'
|
|
||||||
},
|
|
||||||
{ title: 'Fetching Data', path: '/account/members/team-info' },
|
|
||||||
{ title: 'Custom Reports', path: '/account/home/user-profile' },
|
|
||||||
{
|
|
||||||
title: 'Real Time Analytics',
|
|
||||||
path: '/account/home/settings-enterprise'
|
|
||||||
},
|
|
||||||
{ title: 'Exporting Data', path: '/account/members/import-members' },
|
|
||||||
{ title: 'Dashboard Integration', path: '/account/members/team-info' }
|
|
||||||
],
|
|
||||||
[] // Empty dependency array since the data is static
|
|
||||||
);
|
|
||||||
|
|
||||||
const classNames: AccordionMenuClassNames = {
|
|
||||||
root: 'space-y-1',
|
|
||||||
label: 'uppercase text-xs font-medium text-muted-foreground/80 pt-6 mb-2 pb-0',
|
|
||||||
item: 'h-8 hover:bg-background border-accent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium'
|
|
||||||
};
|
|
||||||
|
|
||||||
const matchPath = useCallback(
|
|
||||||
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
|
|
||||||
[pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildDropdown = () => {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
mode="input"
|
|
||||||
className="w-full justify-between focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<currentDropdownItem.icon />
|
|
||||||
{currentDropdownItem.title}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="w-(--radix-dropdown-menu-trigger-width)">
|
|
||||||
{dropdownItems.map((item, index) => (
|
|
||||||
<DropdownMenuItem key={index} asChild>
|
|
||||||
<Link to={item.path}>
|
|
||||||
<item.icon />
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const memoizedMenu = useMemo(
|
|
||||||
() => (
|
|
||||||
<AccordionMenuGroup>
|
|
||||||
{menuItems.map((item, index) =>
|
|
||||||
'label' in item ? (
|
|
||||||
<AccordionMenuLabel key={index}>{item.label}</AccordionMenuLabel>
|
|
||||||
) : (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || `item-${index}`} className="text-sm">
|
|
||||||
<Link to={item.path || '#'}>{item.title}</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</AccordionMenuGroup>
|
|
||||||
),
|
|
||||||
[menuItems]
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildMenu = () => {
|
|
||||||
return (
|
|
||||||
<AccordionMenu
|
|
||||||
selectedValue={'/account/home/settings-sidebar'}
|
|
||||||
matchPath={matchPath}
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
classNames={classNames}
|
|
||||||
>
|
|
||||||
{memoizedMenu}
|
|
||||||
</AccordionMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full space-y-1">
|
|
||||||
{buildDropdown()}
|
|
||||||
{buildMenu()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { JSX, useCallback } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuConfig, MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
|
|
||||||
import { Badge } from '#shared/ui/Badge';
|
|
||||||
|
|
||||||
export function SidebarMenuDefault() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
|
|
||||||
// Memoize matchPath to prevent unnecessary re-renders
|
|
||||||
const matchPath = useCallback(
|
|
||||||
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
|
|
||||||
[pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Global classNames for consistent styling
|
|
||||||
const classNames: AccordionMenuClassNames = {
|
|
||||||
root: 'lg:ps-1 space-y-1',
|
|
||||||
group: 'gap-px',
|
|
||||||
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
|
||||||
separator: '',
|
|
||||||
item: 'border border-transparent h-9 hover:bg-background text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium data-[selected=true]:border-border',
|
|
||||||
sub: '',
|
|
||||||
subTrigger:
|
|
||||||
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-transparent data-[selected=true]:font-medium',
|
|
||||||
subContent: 'ps-3 py-0',
|
|
||||||
indicator: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenu = (items: MenuConfig): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (item.heading) {
|
|
||||||
return buildMenuHeading(item, index);
|
|
||||||
} else if (item.disabled) {
|
|
||||||
return buildMenuItemRootDisabled(item, index);
|
|
||||||
} else {
|
|
||||||
return buildMenuItemRoot(item, index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `root-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-sm font-medium">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `root-${index}`}
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
|
|
||||||
<Link to={item.path || '#'} className="flex 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
|
// DEMO 5
|
||||||
// https://keenthemes.com/metronic/tailwind/react/demo5
|
// https://keenthemes.com/metronic/tailwind/react/demo5
|
||||||
|
|
||||||
import { addDays, format } from 'date-fns';
|
import { Outlet } from 'react-router';
|
||||||
import { CalendarDays } from 'lucide-react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { DateRange } from 'react-day-picker';
|
|
||||||
import { Outlet, useLocation } from 'react-router';
|
|
||||||
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { Button } from '#shared/ui/Button';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
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 { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Navbar } from './components/Navbar';
|
import { Navbar } from './components/Navbar';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
|
|
||||||
|
|
||||||
export const DualRowsHeaderLayout = () => {
|
export const DualRowsHeaderLayout = () => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { pathname } = useLocation();
|
useLayoutSettings(LayoutNames.DUAL_ROW_HEADER);
|
||||||
const { setOption } = useSettings();
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
|
|
||||||
const [date, setDate] = useState<DateRange | undefined>({
|
|
||||||
from: new Date(2025, 0, 20),
|
|
||||||
to: addDays(new Date(2025, 0, 20), 20)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Using the custom hook to set multiple CSS variables and class properties
|
|
||||||
useBodyClass(`
|
useBodyClass(`
|
||||||
[--header-height:54px]
|
[--header-height:54px]
|
||||||
[--sidebar-width:200px]
|
[--sidebar-width:200px]
|
||||||
`);
|
`);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOption('layout', LayoutNames.DUAL_ROW_HEADER);
|
|
||||||
setOption('container', 'fluid');
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height)">
|
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height)">
|
||||||
@@ -46,48 +26,19 @@ export const DualRowsHeaderLayout = () => {
|
|||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="w-full flex px-0 lg:ps-4">
|
<div className="flex w-full px-0 lg:ps-4">
|
||||||
{!isMobile && <Sidebar />}
|
{!isMobile && <Sidebar />}
|
||||||
|
|
||||||
<main className="flex flex-col grow">
|
<main className="flex grow flex-col">
|
||||||
{!pathname.includes('/public-profile/') && (
|
{showToolbar && (
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<ToolbarHeading />
|
<ToolbarHeading />
|
||||||
<ToolbarActions>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button id="date" variant="outline" mode="input">
|
|
||||||
<CalendarDays />
|
|
||||||
{date?.from ? (
|
|
||||||
date.to ? (
|
|
||||||
<span>
|
|
||||||
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
format(date.from, 'LLL dd, y')
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span>Pick a date range</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="end">
|
|
||||||
<Calendar
|
|
||||||
mode="range"
|
|
||||||
defaultMonth={date?.from}
|
|
||||||
selected={date}
|
|
||||||
onSelect={setDate}
|
|
||||||
numberOfMonths={2}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</ToolbarActions>
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
||||||
<Footer />
|
{showFooter && <AppFooter />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Multiple Menus layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 6](https://keenthemes.com/metronic/tailwind/react/demo6)
|
||||||
|
|
||||||
|
A fixed light sidebar holding the whole application menu as an accordion, with the page content in a
|
||||||
|
rounded card beside it. Below `lg` the sidebar collapses into a sheet opened from a compact header.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| ----------------------- | --------------------------------------------- |
|
||||||
|
| Sidebar menu | `SidebarAccordionMenu` from `../shared` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Sidebar`, `SidebarHeader` (logo plus search box), `SidebarFooter`, `Header`
|
||||||
|
(mobile sheet).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `RoosterPeek` is a development-only easter egg kept with this layout; it renders nothing in
|
||||||
|
production builds.
|
||||||
|
- Body variables: `--header-height: 60px`, `--sidebar-width: 270px`.
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useIsMobile } from '@crux-kit/hooks';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
|
import { AppLogo } from '../../shared';
|
||||||
import { SidebarFooter } from './SidebarFooter';
|
import { SidebarFooter } from './SidebarFooter';
|
||||||
import { SidebarHeader } from './SidebarHeader';
|
import { SidebarHeader } from './SidebarHeader';
|
||||||
import { SidebarMenu } from './SidebarMenu';
|
import { SidebarMenu } from './SidebarMenu';
|
||||||
@@ -13,46 +12,31 @@ import { SidebarMenu } from './SidebarMenu';
|
|||||||
const Header = () => {
|
const Header = () => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
// Close sheet when route changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSheetOpen(false);
|
setIsSheetOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex lg:hidden items-center fixed z-10 top-0 start-0 end-0 shrink-0 bg-muted h-(--header-height)">
|
<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 items-center justify-between flex-wrap gap-3">
|
<Container className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Link to="/">
|
<AppLogo />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/logo-light.svg')}
|
|
||||||
className="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>
|
|
||||||
|
|
||||||
{isMobile && (
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
<SheetTrigger asChild>
|
||||||
<SheetTrigger asChild>
|
<Button variant="ghost" mode="icon">
|
||||||
<Button variant="ghost" mode="icon">
|
<Menu />
|
||||||
<Menu />
|
</Button>
|
||||||
</Button>
|
</SheetTrigger>
|
||||||
</SheetTrigger>
|
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
||||||
<SheetContent className="p-0 gap-0 w-68.75" side="left" close={false}>
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
<SheetHeader className="p-0 space-y-0" />
|
<SheetBody className="flex grow flex-col p-0">
|
||||||
<SheetBody className="p-0 flex flex-col grow">
|
<SidebarHeader />
|
||||||
<SidebarHeader />
|
<SidebarMenu />
|
||||||
<SidebarMenu />
|
<SidebarFooter />
|
||||||
<SidebarFooter />
|
</SheetBody>
|
||||||
</SheetBody>
|
</SheetContent>
|
||||||
</SheetContent>
|
</Sheet>
|
||||||
</Sheet>
|
|
||||||
)}
|
|
||||||
</Container>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const RoosterPeek: React.FC = () => {
|
|||||||
<img
|
<img
|
||||||
key={tick}
|
key={tick}
|
||||||
src={toAbsoluteUrl('/media/app/brutal-aggressive-rooster-head-icon.svg')}
|
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)}
|
style={buildStyle(side)}
|
||||||
alt="Brutal rooster"
|
alt="Brutal rooster"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { SidebarMenu } from './SidebarMenu';
|
|||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
return (
|
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 />
|
<SidebarHeader />
|
||||||
<SidebarMenu />
|
<SidebarMenu />
|
||||||
<SidebarFooter />
|
<SidebarFooter />
|
||||||
|
|||||||
@@ -1,25 +1,9 @@
|
|||||||
import { IntlName } from '#infrastructure/IntlName';
|
import { UserAvatarMenu } from '../../shared';
|
||||||
import { useAuth } from '#core/Auth/providers/AuthProvider';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '#shared/ui/Avatar';
|
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
|
||||||
|
|
||||||
export function SidebarFooter() {
|
export function SidebarFooter() {
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-center justify-between shrink-0 ps-4 pe-3.5 h-14">
|
<div className="flex-center flex h-14 shrink-0 justify-between ps-4 pe-3.5">
|
||||||
{user && (
|
<UserAvatarMenu />
|
||||||
<UserDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<Avatar className="size-9 cursor-pointer">
|
|
||||||
<AvatarImage src={undefined} alt="User avatar" />
|
|
||||||
<AvatarFallback className="text-sm">
|
|
||||||
{IntlName.formatToDisplay(user, 'INITIALS_LF')}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,29 @@
|
|||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Input } from '#shared/ui/Input';
|
import { Input } from '#shared/ui/Input';
|
||||||
|
import { AppLogo } from '../../shared';
|
||||||
|
|
||||||
export function SidebarHeader() {
|
export function SidebarHeader() {
|
||||||
const handleInputChange = () => {};
|
const handleInputChange = () => {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-3.5">
|
<div className="mb-3.5">
|
||||||
<div className="flex items-center justify-between gap-2.5 px-3.5 h-17.5">
|
<div className="flex h-17.5 items-center justify-between gap-2.5 px-3.5">
|
||||||
<Link to="/">
|
<AppLogo className="h-10.5" />
|
||||||
<img
|
<div className="shrink-0 dark:text-[#c08183]">
|
||||||
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">
|
|
||||||
C R U X <sub className="text-[50%]">Goat</sub>
|
C R U X <sub className="text-[50%]">Goat</sub>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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
|
<Input
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
className="px-9 min-w-0"
|
className="min-w-0 px-9"
|
||||||
value=""
|
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 + /
|
cmd + /
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { SidebarMenuPrimary } from './SidebarMenuPrimary';
|
import { SidebarAccordionMenu } from '../../shared';
|
||||||
|
|
||||||
export function SidebarMenu() {
|
export function SidebarMenu() {
|
||||||
return (
|
return (
|
||||||
<div className="kt-scrollable-y-auto grow max-h-[calc(100vh-11.5rem)]">
|
<div className="kt-scrollable-y-auto max-h-[calc(100vh-11.5rem)] grow">
|
||||||
<SidebarMenuPrimary />
|
<SidebarAccordionMenu />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { JSX, useCallback } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuConfig, MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useAuth } from '#core/Auth/providers/AuthProvider';
|
|
||||||
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
|
|
||||||
import { getPermittedMenuItems } from '../lib';
|
|
||||||
|
|
||||||
export function SidebarMenuPrimary() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { user } = useAuth();
|
|
||||||
const userPermissions = user?.permissions ?? [];
|
|
||||||
// Memoize matchPath to prevent unnecessary re-renders
|
|
||||||
const matchPath = useCallback(
|
|
||||||
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
|
|
||||||
[pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Global classNames for consistent styling
|
|
||||||
const classNames: AccordionMenuClassNames = {
|
|
||||||
root: 'space-y-2.5 px-3.5',
|
|
||||||
group: 'gap-px',
|
|
||||||
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
|
||||||
separator: '',
|
|
||||||
item: 'h-9 hover:bg-transparent border border-transparent text-accent-foreground hover:text-mono data-[selected=true]:text-mono data-[selected=true]:bg-background data-[selected=true]:border-border data-[selected=true]:font-medium',
|
|
||||||
sub: '',
|
|
||||||
subTrigger:
|
|
||||||
'h-9 hover:bg-transparent border border-transparent text-accent-foreground hover:text-mono data-[selected=true]:text-mono data-[selected=true]:bg-background data-[selected=true]:border-border data-[selected=true]:font-medium',
|
|
||||||
subContent: 'py-0',
|
|
||||||
indicator: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
|
|
||||||
const buildMenu = (items: MenuConfig): JSX.Element[] => {
|
|
||||||
const menuItems = getPermittedMenuItems(items, userPermissions);
|
|
||||||
|
|
||||||
return menuItems.map((item: MenuItem, index: number) => {
|
|
||||||
if (!item.heading && !item.disabled) {
|
|
||||||
return buildMenuItemRoot(item, index);
|
|
||||||
} else {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `root-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-sm font-medium">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `root-${index}`}
|
|
||||||
className="ps-6"
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
|
|
||||||
<Link to={item.path || '#'}>
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (!item.heading && !item.disabled) {
|
|
||||||
return buildMenuItemChild(item, index, level);
|
|
||||||
} else {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-[13px]">
|
|
||||||
{item.collapse ? (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
<span className="hidden [[data-state=open]>span>&]:inline">
|
|
||||||
{item.collapseTitle}
|
|
||||||
</span>
|
|
||||||
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
item.title
|
|
||||||
)}
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `child-${level}-${index}`}
|
|
||||||
className={cn(
|
|
||||||
'ps-4',
|
|
||||||
!item.collapse && 'relative',
|
|
||||||
!item.collapse && (level > 0 ? '' : '')
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>
|
|
||||||
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
|
|
||||||
</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
|
|
||||||
<Link to={item.path || '#'}>{item.title}</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AccordionMenu
|
|
||||||
type="single"
|
|
||||||
selectedValue={pathname}
|
|
||||||
matchPath={matchPath}
|
|
||||||
collapsible
|
|
||||||
classNames={classNames}
|
|
||||||
>
|
|
||||||
{buildMenu(menuConfig)}
|
|
||||||
</AccordionMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
// DEMO 6
|
// DEMO 6
|
||||||
// https://keenthemes.com/metronic/tailwind/react/demo6
|
// https://keenthemes.com/metronic/tailwind/react/demo6
|
||||||
|
|
||||||
import React from 'react';
|
import { Fragment } from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#providers/SettingsProvider';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
import { Footer } from './components/Footer';
|
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Toolbar, ToolbarHeading } from './components/Toolbar';
|
|
||||||
|
|
||||||
export const MultipleMenusLayout = () => {
|
export const MultipleMenusLayout = () => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { setOption } = useSettings();
|
useLayoutSettings(LayoutNames.MULTIPLE_MENUS);
|
||||||
const { pathname } = useLocation();
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
|
|
||||||
useBodyClass(`
|
useBodyClass(`
|
||||||
[--header-height:60px]
|
[--header-height:60px]
|
||||||
@@ -23,46 +21,29 @@ export const MultipleMenusLayout = () => {
|
|||||||
bg-muted!
|
bg-muted!
|
||||||
`);
|
`);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setOption('layout', LayoutNames.MULTIPLE_MENUS);
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
const toolbar = React.useMemo(() => {
|
|
||||||
if (pathname.includes('/devtools')) return null;
|
|
||||||
if (pathname.includes('/access-control')) return null;
|
|
||||||
if (pathname.includes('/file-manager')) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Toolbar>
|
|
||||||
<ToolbarHeading />
|
|
||||||
</Toolbar>
|
|
||||||
);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
const footer = React.useMemo(() => {
|
|
||||||
if (pathname.includes('/file-manager')) return null;
|
|
||||||
return <Footer />;
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<Fragment>
|
||||||
<div className="flex grow">
|
<div className="flex grow">
|
||||||
{!isMobile && <Sidebar />}
|
{!isMobile && <Sidebar />}
|
||||||
|
|
||||||
{isMobile && <Header />}
|
{isMobile && <Header />}
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row grow pt-(--header-height) lg:pt-0">
|
<div className="flex grow flex-col pt-(--header-height) lg:flex-row 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="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="flex flex-col grow kt-scrollable-y-auto [--kt-scrollbar-width:auto] pt-5">
|
<div className="kt-scrollable-y-auto flex grow flex-col pt-5 [--kt-scrollbar-width:auto]">
|
||||||
<main className="grow" role="content">
|
<main className="grow" role="content">
|
||||||
{toolbar}
|
{showToolbar && (
|
||||||
|
<Toolbar>
|
||||||
|
<ToolbarHeading />
|
||||||
|
</Toolbar>
|
||||||
|
)}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{footer}
|
{showFooter && <AppFooter />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</React.Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { MenuItem } from '#app/config/types';
|
|
||||||
|
|
||||||
export function getPermittedMenuItems(items: MenuItem[], userPermission: string[]): MenuItem[] {
|
|
||||||
return items
|
|
||||||
.map((item) => {
|
|
||||||
const filteredChildren = item.children
|
|
||||||
? getPermittedMenuItems(item.children, userPermission)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const hasPermission = item.permissions
|
|
||||||
? item.permissions.every((permission) => userPermission.includes(permission))
|
|
||||||
: true;
|
|
||||||
|
|
||||||
if (hasPermission || (filteredChildren && filteredChildren.length > 0)) {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
children: filteredChildren
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter((item) => item != null)
|
|
||||||
.filter((item) => (item.children ? item.children.length > 0 : true));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Advanced Mega Menu layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 7](https://keenthemes.com/metronic/tailwind/react/demo7)
|
||||||
|
|
||||||
|
A header-only layout: the whole application menu lives in a mega menu beside the logo, and the page
|
||||||
|
content runs full width beneath it. The header becomes sticky and shrinks past
|
||||||
|
`layouts.advanced-mega-menu-layout.headerStickyOffset`.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| ----------------------- | --------------------------------------------- |
|
||||||
|
| Mega menu | `MegaNavMenu` from `../shared` |
|
||||||
|
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Header`, `HeaderLogo`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The mega menu panels show two levels — a section's children as columns, their children as links.
|
||||||
|
Deeper entries stay reachable through the toolbar breadcrumbs and the mobile accordion.
|
||||||
|
- The `data-sticky-header` body attribute is removed on unmount.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { generalSettings } from '#app/config/general';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-col 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} ©</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 { useScrollPosition } from '@crux-kit/hooks';
|
||||||
import { cn } from '@crux-kit/utils';
|
import { cn } from '@crux-kit/utils';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
|
import { useSettings } from '#app/providers/SettingsProvider';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
import { useSettings } from '#providers/SettingsProvider';
|
import { UserAvatarMenu } from '../../shared';
|
||||||
import { HeaderLogo } from './HeaderLogo';
|
import { HeaderLogo } from './HeaderLogo';
|
||||||
import { HeaderTopbar } from './HeaderTopbar';
|
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const scrollPosition = useScrollPosition();
|
const scrollPosition = useScrollPosition();
|
||||||
const [headerStickyOn, setHeaderStickyOn] = useState(false);
|
const [headerStickyOn, setHeaderStickyOn] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const { headerSticky, headerStickyOffset } = settings.layouts[LayoutNames.ADVANCE_MEGA_MENU];
|
||||||
const isSticky =
|
|
||||||
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
|
|
||||||
setHeaderStickyOn(isSticky);
|
|
||||||
}, [scrollPosition, settings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (headerStickyOn === true) {
|
setHeaderStickyOn(scrollPosition > headerStickyOffset);
|
||||||
|
}, [scrollPosition, headerStickyOffset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (headerStickyOn) {
|
||||||
document.body.setAttribute('data-sticky-header', 'on');
|
document.body.setAttribute('data-sticky-header', 'on');
|
||||||
} else {
|
} else {
|
||||||
document.body.removeAttribute('data-sticky-header');
|
document.body.removeAttribute('data-sticky-header');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return () => document.body.removeAttribute('data-sticky-header');
|
||||||
}, [headerStickyOn]);
|
}, [headerStickyOn]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center transition-[height] shrink-0 bg-background py-4 lg:py-0 lg:h-(--header-height)',
|
'bg-background flex shrink-0 items-center py-4 transition-[height] lg:h-(--header-height) lg:py-0',
|
||||||
headerStickyOn &&
|
headerSticky &&
|
||||||
'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)'
|
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 />
|
<HeaderLogo />
|
||||||
<HeaderTopbar />
|
<UserAvatarMenu />
|
||||||
</Container>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useIsMobile } from '@crux-kit/hooks';
|
import { useIsMobile } from '@crux-kit/hooks';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { MegaMenu } from './MegaMenu';
|
import { AppLogo, AppWordmark, MegaNavMenu, SidebarAccordionMenu } from '../../shared';
|
||||||
import { MegaMenuMobile } from './MegaMenuMobile';
|
|
||||||
|
|
||||||
const HeaderLogo = () => {
|
const HeaderLogo = () => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -18,26 +16,14 @@ const HeaderLogo = () => {
|
|||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2.5">
|
||||||
<Link to="/">
|
<AppLogo className="size-8.5" />
|
||||||
<img
|
<AppWordmark className="text-mono hidden text-lg font-medium lg:block" />
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-circle-primary.svg')}
|
|
||||||
className="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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isMobile ? (
|
{!isMobile ? (
|
||||||
<MegaMenu />
|
<MegaNavMenu />
|
||||||
) : (
|
) : (
|
||||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
@@ -45,10 +31,10 @@ const HeaderLogo = () => {
|
|||||||
<Menu />
|
<Menu />
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent className="p-0 gap-0 w-68.75" side="left" close={false}>
|
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
||||||
<SheetHeader className="p-0 space-y-0" />
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
<SheetBody className="p-0 flex flex-col grow">
|
<SheetBody className="flex grow flex-col overflow-y-auto py-5">
|
||||||
<MegaMenuMobile />
|
<SidebarAccordionMenu />
|
||||||
</SheetBody>
|
</SheetBody>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||