Compare commits

...
6 Commits
Author SHA1 Message Date
Igor Martyniuk aae77ebc27 docs: update changelog for v1.3.1 2026-08-31 15:43:21 +03:00
Igor Martyniuk 3556662ddd Merge branch 'fix/misc-fixes' into 'main'
TASKS-1832

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

See merge request crux/admin!30
2026-08-28 08:35:58 +00:00
Igor Martyniuk c93d8c025b TASKS-1823 WebManager module 2026-08-28 08:35:58 +00:00
671 changed files with 26541 additions and 6175 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules
.next
npm-debug.log
.git
+2 -2
View File
@@ -26,7 +26,7 @@ yarn-error.log
# Platform specific
.DS_Store
*.tsbuildinfo
/apps/backend/public/
/apps/backend/storage/
/apps/backend/.adonisjs/
/apps/backend/database/schema.ts
+2
View File
@@ -1 +1,3 @@
npm run test:admin
npm run test:backend
npm run test:web
+6 -3
View File
@@ -1,10 +1,13 @@
# Dependencies and generated output
**/node_modules/**
**/build/**
**/dist/**
**/coverage/**
**/.next/**
apps/backend/public/**
apps/backend/storage/**
# Handlebars generator templates contain application syntax that is not valid HTML.
apps/backend/app/templates/**
apps/backend/.adonisjs/**
apps/backend/database/schema.ts
apps/web/.next/**
+74 -5
View File
@@ -9,17 +9,86 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
- Added `@crux-kit/editor-tiptap`, a free, headless rich-text editor package built on TipTap. The `light` preset covers bold, italic, underline,
strikethrough, headings, lists, blockquote, text alignment, links, and undo/redo, reading and
writing content as plain HTML so it themes with the shared design-system tokens.
- Added the `InputTiptapEditor` composed form field.
### Changed
### 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
+133 -55
View File
@@ -1,3 +1,18 @@
# CRUX
A monorepo starter for products that need an admin panel, an API, and a public website. It ships an
AdonisJS backend, a React admin panel with a module generator, and a Next.js website that renders
the pages editors publish through the built-in CMS.
| App | Source | Dev port | What it is |
| --------------- | --------------- | -------- | ------------------------------------------------------ |
| Backend API | `apps/backend/` | 3333 | AdonisJS — data, auth, permissions, public content API |
| Admin panel | `apps/admin/` | 5173 | React + Vite — the internal UI |
| Public website | `apps/web/` | 3000 | Next.js — the site visitors see |
| Shared packages | `packages/` | — | UI, hooks, services, types, utils |
Full documentation lives in [`docs/`](./docs/Readme.md).
### Tech Stack
#### Backend
@@ -6,94 +21,157 @@
- TypeScript
- Lucid ORM
- PostgreSQL / MySQL (configurable)
- Redis, S3 / MinIO storage
#### Frontend
#### Admin panel
- React
- TypeScript
- Vite
- React Query / Mobx / Axios
- Component-based UI system
- React Query / MobX / Axios
- Radix UI + Tailwind component system
- React Hook Form + Zod
#### Public website
- Next.js (App Router, React Server Components)
- TypeScript
- next-intl
- Tailwind CSS
### Getting Started
#### Prerequisites
Node.js >= v24.18.0
npm >= 11.15.0
Database PostgreSQL
- Node.js `24.18.0` (see `.nvmrc`; the package manifest requires `>=24.0.0`)
- npm `>=11.6.2` (the repo pins `npm@11.15.0`)
- PostgreSQL
#### Installation
```
```bash
git clone gitlab@git.rexsoftproduction.com:crux/admin.git
cd crux
npm install
```
```bash
cd crux && npm install --force
```
> `--force` is required. `typescript-eslint` does not yet declare support for the TypeScript
> version this repo uses, so a plain `npm install` fails with `ERESOLVE`. Never substitute
> `--legacy-peer-deps` — it strips nested peer dependencies and breaks the backend at runtime. Use
> `npm ci --force` for a reproducible install, and `npm install <package> --force` when adding one.
> See [troubleshooting.md](./troubleshooting.md).
### Configuration
Environment variables are defined in root `.env`
Each workspace defines its own environment variables. Copy the workspace's `.env.example` to `.env` and fill in the values:
```
TZ=UTC
PORT=3333
HOST=localhost
LOG_LEVEL=info
APP_KEY=
NODE_ENV=development
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_DATABASE=adonis
VITE_API_URL=http://0.0.0.0:3333
VITE_TINYMCE_API_KEY=
| Env file | Covers |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/backend/.env` | Server (`PORT`, `APP_KEY`), database (`DB_*`), storage (`AWS_*`, `S3_*`), and the public site link (`WEB_APP_URL`, `WEB_REVALIDATE_SECRET`) used to purge its cache |
| `apps/admin/.env` | `VITE_API_URL` (backend), `VITE_WEB_URL` (public site, used for page previews), `VITE_TINYMCE_API_KEY` |
| `apps/web/.env` | `BACKEND_API_URL` (backend base URL for server rendering), `REVALIDATE_SECRET` (must match the backend's `WEB_REVALIDATE_SECRET`), `SITE_URL` (canonical origin) |
`docker-compose.yml` loads each service's env from the same per-app files. Inside Docker, service
URLs must use compose service names (`http://backend:3333`), not `localhost`.
### Running
#### Locally
| Command | Description |
| ----------------------- | ----------------------------------------------- |
| `npm run dev:backend` | Start the AdonisJS backend in development mode |
| `npm run dev:admin-app` | Start the React admin application |
| `npm run dev:web` | Start the Next.js web application |
| `npm run dev:all` | Start backend, admin, and web apps concurrently |
#### With Docker
```bash
docker compose up -d
```
### Module Generator
Brings up everything: backend (3333), admin (5173), web (3000), PostgreSQL (5432), Redis (6379),
MinIO (9000 / console 9001) and pgAdmin (5050).
The module generator allows you to create full CRUD modules by selecting:
Two quirks worth knowing when a change appears not to take effect:
- The web container keeps `.next` on an anonymous volume that survives rebuilds. Clear it with
`docker compose up -d --force-recreate --renew-anon-volumes web`.
- Backend HMR does not always pick up edits on macOS (bind-mount file events); restart
`crux_backend_adonis` to load them.
### Scripts
#### Build
| Command | Description |
| ----------------------- | ------------------------------------------ |
| `npm run build:backend` | Build the backend application |
| `npm run build:admin` | Type-check and build the admin application |
| `npm run build:web` | Type-check and build the Next.js web app |
#### Test
| Command | Description |
| ---------------------- | ---------------------------------------------- |
| `npm run test:backend` | Backend functional tests (requires a database) |
| `npm run test:admin` | Admin unit tests (Vitest) |
| `npm run test:web` | Web app unit tests (Vitest) |
#### Lint and format
| Command | Description |
| ---------------------- | -------------------------- |
| `npm run lint` | ESLint across the monorepo |
| `npm run lint:fix` | ESLint with autofix |
| `npm run format` | Prettier write |
| `npm run format:check` | Prettier check, no writes |
#### Versions
| Command | Description |
| ----------------------- | ------------------------------------ |
| `npm run version:patch` | Update patch version of all packages |
| `npm run version:minor` | Update minor version of all packages |
| `npm run version:major` | Update major version of all packages |
> Versions are updated without creating git tags.
### Features
#### Module Generator
Create full CRUD modules by selecting:
- Database table
- Fields and types
- Validation rules
- List view configuration
#### Generated Output
**Generated output** — Backend: controllers, validators, routes. Frontend: pages, forms, tables, API.
**Backend**: controllers, validators, routes
**Frontend**: pages, forms, tables, API
#### Authentication & Authorization
### Authentication & Authorization
- JWT / Session-based authentication
- JWT / session-based authentication
- Role & permission system
- Policy-based access control
### Scripts
#### Content Management
#### Development
Web Manager is the built-in CMS: editors build localized pages from prebuilt blocks in the admin
panel, preview them privately, and publish to the Next.js site, which purges only the cache entries
the change affects. See [Web Manager](./docs/core/web-manager/Readme.md).
| Command | Description |
| ----------------------- | ---------------------------------------------- |
| `npm run dev:backend` | Start the AdonisJS backend in development mode |
| `npm run dev:admin-app` | Start the React admin application |
| `npm run dev:all` | Start backend and admin app concurrently |
### Documentation
#### Build
| Command | Description |
| ----------------------- | ----------------------------- |
| `npm run build:backend` | Build the backend application |
| `npm run build:admin` | Build the admin application |
#### Versions
| Command | Description |
| --------------- | ------------------------------------ |
| `npm run patch` | Update Patch version of all packages |
| `npm run minor` | Update Minor version of all packages |
| `npm run major` | Update Major version of all packages |
> Versions are updated without creating git tags.
| Topic | Where |
| ------------------------ | ---------------------------------------------------- |
| Documentation home | [docs/Readme.md](./docs/Readme.md) |
| Public website | [apps/web/Readme.md](./apps/web/Readme.md) |
| Architecture | [docs/architecture/](./docs/architecture/Readme.md) |
| Core modules | [docs/core/](./docs/Readme.md#core-modules) |
| Shared packages | [docs/packages/Readme.md](./docs/packages/Readme.md) |
| Install and setup issues | [troubleshooting.md](./troubleshooting.md) |
+2 -1
View File
@@ -1,2 +1,3 @@
VITE_API_URL=http://0.0.0.0:3333
VITE_API_URL=http://localhost:3333
VITE_TINYMCE_API_KEY=
VITE_WEB_URL=http://localhost:3000
-8
View File
@@ -1,8 +0,0 @@
# Metronic 9 | All-in-One Tailwind based HTML/React/Next.js Template for Modern Web Applications
### Prerequisites
- Node.js 22.14.0 or higher
- Npm or Yarn
- Tailwind CSS 4.x
- React 19.x
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@crux-kit/admin-app",
"private": true,
"version": "1.2.0",
"version": "1.3.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" role="img">
<path
fill="#E05454"
d="m 7.7215115,7.835445 4.3544155,-2.303813 0,4.987339 L 7.7468165,13 7.7215165,7.784809"></path>
<path
fill="#C13383"
d="M 1.9240383,5.531658 6.2531745,7.886082 6.2278445,13 1.8987349,10.518997 1.9240399,5.556964"></path>
<path
fill="#792CA2"
d="M 6.9873475,1 1.8987345,3.936711 6.9873475,6.670899 12.101266,3.936711 6.9873475,1"></path>
</svg>

After

Width:  |  Height:  |  Size: 503 B

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

After

Width:  |  Height:  |  Size: 502 B

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

After

Width:  |  Height:  |  Size: 1.8 KiB

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

After

Width:  |  Height:  |  Size: 692 B

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

After

Width:  |  Height:  |  Size: 1.0 KiB

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

After

Width:  |  Height:  |  Size: 1.1 KiB

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

After

Width:  |  Height:  |  Size: 800 B

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

After

Width:  |  Height:  |  Size: 415 B

+4
View File
@@ -0,0 +1,4 @@
import { createContext } from 'react';
import { type I18nProviderProps } from '#app/i18n/types';
export default createContext<I18nProviderProps | null>(null);
+99 -1
View File
@@ -1,7 +1,105 @@
{
"ACTION.BACK": "رجوع",
"ACTION.SAVE": "حفظ",
"ACTION.CREATE": "إنشاء",
"ACTION.INCLUDE": "تضمين",
"ACTION.GENERATE": "توليد",
"ACTION.REMOVE": "إزالة",
"ACTION.CANCEL": "إلغاء",
"ACTION.CONFIRM": "تأكيد",
"ACTION.SUBMIT": "إرسال",
"ACTION.SEARCH": "بحث",
"ACTION.SWAP": "تبديل",
"ACTION.MERGE": "دمج",
"ACTION.SPLIT": "تقسيم",
"ACTION.RESET": "إعادة تعيين",
"ACTION.PREVIEW": "معاينة",
"ACTION.UPLOAD": "رفع",
"ACTION.DELETE": "حذف",
"ACTION.CLOSE": "إغلاق",
"ACTION.CLEAR": "مسح",
"ACTION.CLEAR_FILTERS": "مسح عوامل التصفية",
"ACTION.EDIT": "تعديل",
"STATE.CREATING": "جارٍ الإنشاء",
"STATE.GENERATING": "جارٍ التوليد",
"STATE.SAVING": "جارٍ الحفظ",
"STATE.REMOVING": "جارٍ الإزالة",
"STATE.LOADING": "جارٍ التحميل",
"STATE.UPDATING": "جارٍ التحديث",
"ERROR": "خطأ",
"PERMISSION": "صلاحية",
"PERMISSION.PERMISSIONS": "الصلاحيات",
"PERMISSION.PERMISSION_NAME": "اسم الصلاحية",
"PERMISSION.SLUG": "Slug",
"PERMISSION.EDIT_PERMISSION": "تعديل الصلاحية",
"ROLE": "دور",
"ROLE.ROLES": "الأدوار",
"ROLE.ROLE_NAME": "اسم الدور",
"ROLE.SLUG": "Slug",
"ROLE.EDIT_ROLE": "تعديل الدور",
"USER": "مستخدم",
"USER.EDIT_USER": "تعديل المستخدم",
"USER.STATUS.ACTIVE": "نشط",
"USER.STATUS.INACTIVE": "غير نشط",
"USER.MENU.MY_PROFILE": "الملف الشخصي الخاص بي",
"USER.MENU.MY_ACCOUNT": "حسابي",
"USER.MENU.LANGUAGE": "لغة",
"USER.MENU.DARK_MODE": "الوضع المظلم",
"USER.MENU.LOGOUT": "تسجيل الخروج",
"USER.MENU.LANGUAGE": "لغة"
"SUCCESS": "تم بنجاح",
"ACTIONS": "الإجراءات",
"FILTER": "تصفية",
"SELECTED": "محدد",
"NAME": "الاسم",
"FIRST_NAME": "الاسم الأول",
"LAST_NAME": "اسم العائلة",
"EMAIL": "البريد الإلكتروني",
"SELECT": "اختر",
"SELECT_OPTION": "اختر خياراً",
"SELECT_ALL": "تحديد الكل",
"SELECT_ALL_WITH_OPTS_LENGTH": "تحديد الكل ({length} خيارات)",
"STATUS": "الحالة",
"PASSWORD": "كلمة المرور",
"NEW_PASSWORD": "كلمة المرور الجديدة",
"PASSWORD_CONFIRM": "تأكيد كلمة المرور",
"MODULE_NAME": "اسم الوحدة",
"RETURN_HOME": "العودة إلى الرئيسية",
"NO_ITEMS_FOUND": "لم يتم العثور على عناصر",
"NO_RESULTS_FOUND": "لم يتم العثور على نتائج",
"PLUS_MORE_WITH_NUMBER": "+ {number} أخرى",
"ADMIN.ADMIN_MANAGEMENT": "إدارة المشرفين",
"ADMIN.ADMINS": "المشرفون",
"DASHBOARD": "لوحة التحكم",
"FORM.ERROR.REQUIRED": "مطلوب",
"FORM.ERROR.INVALID_EMAIL_ADDRESS": "عنوان بريد إلكتروني غير صالح",
"FORM.ERROR.PASSWORDS_MUST_MATCH": "يجب أن تتطابق كلمتا المرور",
"FORM.ERROR.MIN_PASSWORD_LENGTH_DESC": "الحد الأدنى لطول كلمة المرور - 8 أحرف.",
"FORM.ERROR.SOMETHING_WENT_WRONG": "حدث خطأ ما",
"TABLE.COMMON.COLUMNS": "الأعمدة",
"TABLE.COMMON.NO_DATA_AVAILABLE": "لا توجد بيانات متاحة",
"TABLE.COMMON.ROWS_PER_PAGE": "عدد الصفوف في الصفحة",
"TABLE.COMMON.GO_TO_PREVIOUS_PAGE": "الانتقال إلى الصفحة السابقة",
"TABLE.COMMON.GO_TO_NEXT_PAGE": "الانتقال إلى الصفحة التالية",
"PROFILE.PROFILE_SETTINGS": "إعدادات الملف الشخصي",
"PROFILE.AVATAR_DESCRIPTION": "نوصي باستخدام صور لا تقل أبعادها عن 98 × 98 بكسل (بحجم يصل إلى 4 ميغابايت) بصيغة PNG أو JPG أو JPEG.",
"PROFILE.AVATAR_ALT": "الصورة الرمزية",
"PROFILE.AVATAR_SETTINGS": "إعدادات الصورة الرمزية",
"PROFILE.AVATAR_CROP_ME": "قص الصورة",
"PROFILE.EDIT_PROFILE_IMAGE": "تعديل صورة الملف الشخصي",
"MODULE.COMMON.REMOVE_ITEM_QUESTION": "هل أنت متأكد من رغبتك في إزالة {target}؟",
"MODULE.COMMON.ARE_YOU_SURE?": "هل أنت متأكد؟",
"MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC": "لا يمكن التراجع عن هذا الإجراء.",
"MODULE.COMMON.CREATE_NEW_ENTITY": "إنشاء {entity} جديد",
"MODULE.COMMON.CREATE_NEW_USER": "إنشاء مستخدم جديد",
"MESSAGE.PLEASE_WAIT": "يرجى الانتظار",
"MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS": "يرجى الانتظار، قد يستغرق هذا بعض الوقت",
"COMMON.SELECT_FILE_TO_VIEW_ITS_CONTENT": "اختر ملفاً لعرض محتوياته",
"COMPONENT.FILE_INPUT.SELECTED_NUM_OF_FILES": "تم اختيار {num} ملف",
"COMPONENT.FILE_INPUT.SELECT_FILE_NO_FILE_SELECTED": "اختر ملفاً، لم يتم اختيار أي ملف",
"COMPONENT.FILE_INPUT.OPTION.FROM_CLOUD_STORAGE": "من التخزين السحابي",
"COMPONENT.FILE_INPUT.OPTION.FROM_LOCAL_MACHINE": "من الجهاز المحلي",
"COMPONENT.FILE_INPUT.OPTION.CLEAR": "مسح",
"COMPONENT.FILE_INPUT.SHOW_SELECTED_FILES": "عرض الملفات المختارة",
"COMPONENT.FILE_INPUT.SELECTED_FILES": "الملفات المختارة",
"COMPONENT.FILE_INPUT.REMOVE_NAME": "إزالة {name}"
}
-15
View File
@@ -20,28 +20,23 @@
"ACTION.CLEAR": "Clear",
"ACTION.CLEAR_FILTERS": "Clear filters",
"ACTION.EDIT": "Edit",
"STATE.CREATING": "Creating",
"STATE.GENERATING": "Generating",
"STATE.SAVING": "Saving",
"STATE.REMOVING": "Removing",
"STATE.LOADING": "Loading",
"STATE.UPDATING": "Updating",
"ERROR": "Error",
"PERMISSION": "Permission",
"PERMISSION.PERMISSIONS": "Permissions",
"PERMISSION.PERMISSION_NAME": "Permission name",
"PERMISSION.SLUG": "Slug",
"PERMISSION.EDIT_PERMISSION": "Edit permission",
"ROLE": "Role",
"ROLE.ROLES": "Roles",
"ROLE.ROLE_NAME": "Role name",
"ROLE.SLUG": "Slug",
"ROLE.EDIT_ROLE": "Edit role",
"USER": "User",
"USER.EDIT_USER": "Edit user",
"USER.STATUS.ACTIVE": "Active",
@@ -51,7 +46,6 @@
"USER.MENU.LANGUAGE": "Language",
"USER.MENU.DARK_MODE": "Dark Mode",
"USER.MENU.LOGOUT": "Logout",
"SUCCESS": "Success",
"ACTIONS": "Actions",
"FILTER": "Filter",
@@ -73,42 +67,33 @@
"NO_ITEMS_FOUND": "No items found",
"NO_RESULTS_FOUND": "No results found",
"PLUS_MORE_WITH_NUMBER": "+ {number} more",
"ADMIN.ADMIN_MANAGEMENT": "Admin Management",
"ADMIN.ADMINS": "Admins",
"DASHBOARD": "Dashboard",
"FORM.ERROR.REQUIRED": "Required",
"FORM.ERROR.INVALID_EMAIL_ADDRESS": "Invalid email address",
"FORM.ERROR.PASSWORDS_MUST_MATCH": "Passwords must match",
"FORM.ERROR.MIN_PASSWORD_LENGTH_DESC": "Min. password length - 8 chars.",
"FORM.ERROR.SOMETHING_WENT_WRONG": "Something went wrong",
"TABLE.COMMON.COLUMNS": "Columns",
"TABLE.COMMON.NO_DATA_AVAILABLE": "No data available",
"TABLE.COMMON.ROWS_PER_PAGE": "Rows per page",
"TABLE.COMMON.GO_TO_PREVIOUS_PAGE": "Go to previous page",
"TABLE.COMMON.GO_TO_NEXT_PAGE": "Go to next page",
"PROFILE.PROFILE_SETTINGS": "Profile Settings",
"PROFILE.AVATAR_DESCRIPTION": "We recommend using images that are at least 98 x 98 pixels in size (file size up to 4 MB) in PNG, JPG, JPEG format.",
"PROFILE.AVATAR_ALT": "Avatar",
"PROFILE.AVATAR_SETTINGS": "Avatar Settings",
"PROFILE.AVATAR_CROP_ME": "Crop me",
"PROFILE.EDIT_PROFILE_IMAGE": "Edit profile image",
"MODULE.COMMON.REMOVE_ITEM_QUESTION": "Are you sure you want to remove {target}?",
"MODULE.COMMON.ARE_YOU_SURE?": "Are you sure?",
"MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC": "This action cannot be undone.",
"MODULE.COMMON.CREATE_NEW_ENTITY": "Create new {entity}",
"MODULE.COMMON.CREATE_NEW_USER": "Create new user",
"MESSAGE.PLEASE_WAIT": "Please wait",
"MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS": "Please wait, this may take few moments",
"COMMON.SELECT_FILE_TO_VIEW_ITS_CONTENT": "Select a file to view its contents",
"COMPONENT.FILE_INPUT.SELECTED_NUM_OF_FILES": "{num} file(s) selected",
"COMPONENT.FILE_INPUT.SELECT_FILE_NO_FILE_SELECTED": "Select file, no file selected",
"COMPONENT.FILE_INPUT.OPTION.FROM_CLOUD_STORAGE": "From cloud storage",
-15
View File
@@ -20,28 +20,23 @@
"ACTION.CLEAR": "Очистити",
"ACTION.CLEAR_FILTERS": "Очистити фільтри",
"ACTION.EDIT": "Редагувати",
"STATE.CREATING": "Створення",
"STATE.GENERATING": "Генерування",
"STATE.SAVING": "Збереження",
"STATE.REMOVING": "Видалення",
"STATE.LOADING": "Завантаження",
"STATE.UPDATING": "Оновлюється",
"ERROR": "Помилка",
"PERMISSION": "Дозвіл",
"PERMISSION.PERMISSIONS": "Дозволи",
"PERMISSION.PERMISSION_NAME": "Назва дозволу",
"PERMISSION.SLUG": "Slug",
"PERMISSION.EDIT_PERMISSION": "Редагувати дозвіл",
"ROLE": "Роль",
"ROLE.ROLES": "Ролі",
"ROLE.ROLE_NAME": "Назва ролі",
"ROLE.SLUG": "Slug",
"ROLE.EDIT_ROLE": "Редагувати роль",
"USER": "Користувач",
"USER.EDIT_USER": "Редагувати користувача",
"USER.STATUS.ACTIVE": "Активний",
@@ -51,7 +46,6 @@
"USER.MENU.LANGUAGE": "Мова",
"USER.MENU.DARK_MODE": "Темний режим",
"USER.MENU.LOGOUT": "Вийти",
"SUCCESS": "Успіх",
"ACTIONS": "Дії",
"FILTER": "Фільтр",
@@ -73,42 +67,33 @@
"NO_ITEMS_FOUND": "Не знайдено жодного елемента",
"NO_RESULTS_FOUND": "Результатів не знайдено",
"PLUS_MORE_WITH_NUMBER": "+ {number} ще",
"ADMIN.ADMIN_MANAGEMENT": "Адмін Управління",
"ADMIN.ADMINS": "Адміністратори",
"DASHBOARD": "Дашборд",
"FORM.ERROR.REQUIRED": "Обов'язково",
"FORM.ERROR.INVALID_EMAIL_ADDRESS": "Недійсна адреса електронної пошти",
"FORM.ERROR.PASSWORDS_MUST_MATCH": "Паролі мають збігатися",
"FORM.ERROR.MIN_PASSWORD_LENGTH_DESC": "Мінімальна довжина пароля - 8 символів.",
"FORM.ERROR.SOMETHING_WENT_WRONG": "Щось пішло не так",
"TABLE.COMMON.COLUMNS": "Колонки",
"TABLE.COMMON.NO_DATA_AVAILABLE": "Немає доступних даних",
"TABLE.COMMON.ROWS_PER_PAGE": "Рядків на сторінці",
"TABLE.COMMON.GO_TO_PREVIOUS_PAGE": "Перейти на попередню сторінку",
"TABLE.COMMON.GO_TO_NEXT_PAGE": "Перейти на наступну сторінку",
"PROFILE.PROFILE_SETTINGS": "Налаштування профілю",
"PROFILE.AVATAR_DESCRIPTION": "Радимо використовувати зображення розміром принаймні 98 x 98 пікселів (розмір файлу - до 4 МБ) у форматі PNG, JPG , JPEG.",
"PROFILE.AVATAR_ALT": "Аватар",
"PROFILE.AVATAR_SETTINGS": "Налаштування аватара",
"PROFILE.AVATAR_CROP_ME": "Обріж мене",
"PROFILE.EDIT_PROFILE_IMAGE": "Редагувати зображення профілю",
"MODULE.COMMON.REMOVE_ITEM_QUESTION": "Ви впевнені, що хочете видалити {target}?",
"MODULE.COMMON.ARE_YOU_SURE?": "Ви впевнені?",
"MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC": "Цю дію не можна скасувати.",
"MODULE.COMMON.CREATE_NEW_ENTITY": "Створити новий {entity}",
"MODULE.COMMON.CREATE_NEW_USER": "Створити нового користувача",
"MESSAGE.PLEASE_WAIT": "Будь ласка, зачекайте",
"MESSAGE.PLEASE_WAIT_THIS_MAY_TAKE_FEW_MOMENTS": "Зачекайте хвилинку, це може зайняти деякий час",
"COMMON.SELECT_FILE_TO_VIEW_ITS_CONTENT": "Виберіть файл, щоб переглянути його вміст",
"COMPONENT.FILE_INPUT.SELECTED_NUM_OF_FILES": "Вибрано {num} файл(и)",
"COMPONENT.FILE_INPUT.SELECT_FILE_NO_FILE_SELECTED": "Виберіть файл, файл не вибрано",
"COMPONENT.FILE_INPUT.OPTION.FROM_CLOUD_STORAGE": "З хмарного сховища",
+20
View File
@@ -0,0 +1,20 @@
import { useMemo } from 'react';
import useLanguage from '#app/i18n/useLanguage';
import { IntlDate } from '#infrastructure/IntlDate';
type DisplayFormat = Parameters<typeof IntlDate.formatToDisplay>[0];
type SourceFormat = Parameters<typeof IntlDate.parseSource>[1];
const useIntlDate = () => {
const { currenLanguage } = useLanguage();
return useMemo(
() =>
(value: string, format: DisplayFormat, sourceFormat: SourceFormat = 'UNIVERSAL_ISO') =>
IntlDate.parseSource(value, sourceFormat).formatToDisplay(format),
// eslint-disable-next-line react-hooks/exhaustive-deps
[currenLanguage.code]
);
};
export default useIntlDate;
+18
View File
@@ -0,0 +1,18 @@
import { useMemo } from 'react';
import useLanguage from '#app/i18n/useLanguage';
import { IntlName } from '#infrastructure/IntlName';
type NameInput = Parameters<typeof IntlName.formatToDisplay>[0];
type NameFormat = Parameters<typeof IntlName.formatToDisplay>[1];
const useIntlName = () => {
const { currenLanguage } = useLanguage();
return useMemo(
() => (input: NameInput, format: NameFormat) => IntlName.formatToDisplay(input, format),
// eslint-disable-next-line react-hooks/exhaustive-deps
[currenLanguage.code]
);
};
export default useIntlName;
+12
View File
@@ -0,0 +1,12 @@
import { useContext } from 'react';
import TranslationsContext from '#app/i18n/context';
const useLanguage = () => {
const context = useContext(TranslationsContext);
if (!context) throw new Error('useLanguage must be used within I18nProvider');
return context;
};
export default useLanguage;
@@ -1,10 +1,11 @@
'use client';
import { JSX, useCallback } from 'react';
import { JSX, useMemo } 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 { createMenuPathMatcher } from '#shared/lib/menuPath';
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
import { Badge } from '#shared/ui/Badge';
@@ -13,9 +14,9 @@ export function SidebarMenu() {
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]
const matchPath = useMemo(
() => createMenuPathMatcher(menuConfig, pathname),
[menuConfig, pathname]
);
// Global classNames for consistent styling
@@ -1,9 +1,11 @@
import { ChevronDown } from 'lucide-react';
import { useMemo } from '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 { createMenuPathMatcher } from '#shared/lib/menuPath';
import { Menubar, MenubarContent, MenubarItem, MenubarMenu, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger } from '#shared/ui/MenuBar';
export function NavbarMenu() {
@@ -24,7 +26,12 @@ export function NavbarMenu() {
navbarMenu = menuConfig?.[3];
}
const { isActive, hasActiveChild } = useMenu(pathname);
const { hasActiveChild } = useMenu(pathname);
// A leaf highlights only when nothing deeper in the menu matches the route more closely.
const isActive = useMemo(
() => createMenuPathMatcher((navbarMenu?.children ?? []) as MenuConfig, pathname),
[navbarMenu, pathname]
);
const buildMenu = (items: MenuConfig) => {
return items.map((item, index) => {
@@ -1,21 +1,23 @@
'use client';
import { JSX, useCallback } from 'react';
import { JSX, useMemo } 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 { createMenuPathMatcher } from '#shared/lib/menuPath';
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 fullMenuConfig = useMenuConfig();
// Memoize matchPath to prevent unnecessary re-renders
const matchPath = useCallback(
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
[pathname]
const matchPath = useMemo(
() => createMenuPathMatcher(fullMenuConfig, pathname),
[fullMenuConfig, pathname]
);
// Global classNames for consistent styling
@@ -165,8 +167,7 @@ export function SidebarMenuDefault() {
// Memoize the menu to prevent unnecessary re-renders
const { getChildren } = useMenu(pathname);
const menuConfig = useMenuConfig();
const menuItems = getChildren(menuConfig, 0);
const menuItems = getChildren(fullMenuConfig, 0);
return (
<AccordionMenu
@@ -1,9 +1,10 @@
import { JSX, useCallback } from 'react';
import { JSX, useMemo } 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 { createMenuPathMatcher } from '#shared/lib/menuPath';
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
import { Badge } from '#shared/ui/Badge';
@@ -12,9 +13,9 @@ export function SidebarMenuDefault() {
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]
const matchPath = useMemo(
() => createMenuPathMatcher(menuConfig, pathname),
[menuConfig, pathname]
);
// Global classNames for consistent styling
@@ -15,7 +15,6 @@ const Header = () => {
const [isSheetOpen, setIsSheetOpen] = useState(false);
const isMobile = useIsMobile();
// Close sheet when route changes
useEffect(() => {
setIsSheetOpen(false);
}, [pathname]);
@@ -1,10 +1,11 @@
import { IntlName } from '#infrastructure/IntlName';
import useIntlName from '#app/i18n/useIntlName';
import { useAuth } from '#core/Auth/providers/AuthProvider';
import { Avatar, AvatarFallback, AvatarImage } from '#shared/ui/Avatar';
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
export function SidebarFooter() {
const { user } = useAuth();
const formatName = useIntlName();
return (
<div className="flex-center flex h-14 shrink-0 justify-between ps-4 pe-3.5">
@@ -13,9 +14,7 @@ export function SidebarFooter() {
trigger={
<Avatar className="size-9 cursor-pointer">
<AvatarImage src={undefined} alt="User avatar" />
<AvatarFallback className="text-sm">
{IntlName.formatToDisplay(user, 'INITIALS_LF')}
</AvatarFallback>
<AvatarFallback className="text-sm">{formatName(user, 'INITIALS_LF')}</AvatarFallback>
</Avatar>
}
/>
@@ -1,25 +1,27 @@
'use client';
import { JSX, useCallback } from 'react';
import { Fragment, JSX, useMemo } 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 { createMenuPathMatcher } from '#shared/lib/menuPath';
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 menuConfig = useMenuConfig();
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]
const matchPath = useMemo(
() => createMenuPathMatcher(menuConfig, pathname),
[menuConfig, pathname]
);
// Global classNames for consistent styling
const classNames: AccordionMenuClassNames = {
root: 'space-y-2.5 px-3.5',
group: 'gap-px',
@@ -33,8 +35,6 @@ export function SidebarMenuPrimary() {
indicator: ''
};
const menuConfig = useMenuConfig();
const buildMenu = (items: MenuConfig): JSX.Element[] => {
const menuItems = getPermittedMenuItems(items, userPermissions);
@@ -82,7 +82,7 @@ export function SidebarMenuPrimary() {
if (!item.heading && !item.disabled) {
return buildMenuItemChild(item, index, level);
} else {
return <></>;
return <Fragment key={index}></Fragment>;
}
});
};
@@ -1,7 +1,7 @@
// DEMO 6
// https://keenthemes.com/metronic/tailwind/react/demo6
import React from 'react';
import { Fragment, useEffect, useMemo } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types';
@@ -23,14 +23,15 @@ export const MultipleMenusLayout = () => {
bg-muted!
`);
React.useEffect(() => {
useEffect(() => {
setOption('layout', LayoutNames.MULTIPLE_MENUS);
}, [setOption]);
const toolbar = React.useMemo(() => {
const toolbar = useMemo(() => {
if (pathname.includes('/devtools')) return null;
if (pathname.includes('/access-control')) return null;
if (pathname.includes('/file-manager')) return null;
if (pathname.includes('/web-studio')) return null;
return (
<Toolbar>
@@ -39,13 +40,13 @@ export const MultipleMenusLayout = () => {
);
}, [pathname]);
const footer = React.useMemo(() => {
const footer = useMemo(() => {
if (pathname.includes('/file-manager')) return null;
return <Footer />;
}, [pathname]);
return (
<React.Fragment>
<Fragment>
<div className="flex grow">
{!isMobile && <Sidebar />}
@@ -63,6 +64,6 @@ export const MultipleMenusLayout = () => {
</div>
</div>
</div>
</React.Fragment>
</Fragment>
);
};
@@ -4,12 +4,13 @@ import { cn } from '@crux-kit/utils';
import { MenuConfig } from '#app/config/types';
import { useMenuConfig } from '#app/menu';
import { useMenu } from '#shared/hooks/useMenu';
import { createMenuPathMatcher } from '#shared/lib/menuPath';
import { Button } from '#shared/ui/Button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
export function SidebarMenu() {
const { pathname } = useLocation();
const { isActive, hasActiveChild } = useMenu(pathname);
const { hasActiveChild } = useMenu(pathname);
const config = useMenuConfig();
const menuConfig: MenuConfig = [
@@ -40,6 +41,9 @@ export function SidebarMenu() {
}
];
// A leaf highlights only when nothing deeper in the menu matches the route more closely.
const isActive = createMenuPathMatcher(menuConfig, pathname);
const buildMenu = (items: MenuConfig) => {
return items.map((item, index) => (
<div key={index} className="flex flex-col items-center">
@@ -1,10 +1,11 @@
'use client';
import { JSX, useCallback } from 'react';
import { JSX, useMemo } 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 { createMenuPathMatcher } from '#shared/lib/menuPath';
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
export function SidebarMenuPrimary() {
@@ -12,9 +13,9 @@ export function SidebarMenuPrimary() {
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]
const matchPath = useMemo(
() => createMenuPathMatcher(menuConfig, pathname),
[menuConfig, pathname]
);
// Global classNames for consistent styling
+2 -2
View File
@@ -1,4 +1,4 @@
import React from 'react';
import { useMemo } from 'react';
import { useIntl } from 'react-intl';
import { type MenuConfig } from './config/types';
import { createModuleMenuConfig } from './services/modules';
@@ -6,7 +6,7 @@ import { createModuleMenuConfig } from './services/modules';
export const useMenuConfig = (): MenuConfig => {
const intl = useIntl();
return React.useMemo(() => {
return useMemo(() => {
return createModuleMenuConfig(intl);
}, [intl]);
};
@@ -1,4 +1,4 @@
import { type ReactNode } from 'react';
import type { ReactNode } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { BASE_URL } from '#app/config/app';
import { AuthProvider } from '#core/Auth/providers/AuthProvider';
+20 -25
View File
@@ -1,51 +1,48 @@
import { DirectionProvider as RadixDirectionProvider } from '@radix-ui/react-direction';
import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react';
import { DirectionProvider } from '@radix-ui/react-direction';
import { useEffect, useState, type PropsWithChildren } from 'react';
import { IntlProvider } from 'react-intl';
import { LocalStorageService } from '@crux-kit/local-storage';
import { I18N_DEFAULT_LANGUAGE, I18N_LANGUAGES } from '#app/i18n/config';
import { I18nProviderProps, type Language } from '#app/i18n/types';
import TranslationsContext from '#app/i18n/context';
import { type Language, type LanguageCode } from '#app/i18n/types';
import { IntlDate } from '#infrastructure/IntlDate';
import { IntlName } from '#infrastructure/IntlName';
import '@formatjs/intl-relativetimeformat/polyfill';
import '@formatjs/intl-relativetimeformat/locale-data/en';
import '@formatjs/intl-relativetimeformat/locale-data/de';
import '@formatjs/intl-relativetimeformat/locale-data/es';
import '@formatjs/intl-relativetimeformat/locale-data/fr';
import '@formatjs/intl-relativetimeformat/locale-data/ja';
import '@formatjs/intl-relativetimeformat/locale-data/zh';
const storage = new LocalStorageService({ prefix: 'i18n' });
const resolveLanguage = (code?: LanguageCode): Language => {
return I18N_LANGUAGES.find((lang) => lang.code === code) ?? I18N_DEFAULT_LANGUAGE;
};
const getInitialLanguage = () => {
const urlParams = new URLSearchParams(window.location.search);
const langParam = urlParams.get('lang');
// Check if langParam matches a supported language in I18N_LANGUAGES
if (langParam) {
const matchedLanguage = I18N_LANGUAGES.find((lang) => lang.code === langParam);
if (matchedLanguage) {
storage.set('config', matchedLanguage);
storage.set('config', matchedLanguage.code);
return matchedLanguage;
}
}
return storage.get<Language>('config') ?? I18N_DEFAULT_LANGUAGE;
return resolveLanguage(storage.get<LanguageCode>('config'));
};
const initialProps: I18nProviderProps = {
languages: I18N_LANGUAGES,
currenLanguage: getInitialLanguage(),
changeLanguage: (_: Language) => {},
isRTL: () => false
};
const initialLanguage = getInitialLanguage();
const TranslationsContext = createContext<I18nProviderProps>(initialProps);
const useLanguage = () => useContext(TranslationsContext);
IntlDate.setLocaleGlobally(initialLanguage.code);
IntlName.setLanguage(initialLanguage.code);
const I18nProvider = ({ children }: PropsWithChildren) => {
const [currenLanguage, setCurrenLanguage] = useState(initialProps.currenLanguage);
const [currenLanguage, setCurrenLanguage] = useState(initialLanguage);
const changeLanguage = (language: Language) => {
storage.set('config', language);
storage.set('config', language.code);
IntlDate.setLocaleGlobally(language.code);
IntlName.setLanguage(language.code);
setCurrenLanguage(language);
};
@@ -54,7 +51,6 @@ const I18nProvider = ({ children }: PropsWithChildren) => {
};
useEffect(() => {
IntlDate.setLocaleGlobally(currenLanguage.code); // TODO: currently changes only after page reload!
document.documentElement.setAttribute('dir', currenLanguage.direction);
}, [currenLanguage]);
@@ -70,13 +66,12 @@ const I18nProvider = ({ children }: PropsWithChildren) => {
<IntlProvider
messages={currenLanguage.messages}
locale={currenLanguage.code}
defaultLocale={getInitialLanguage().code}
defaultLocale={I18N_DEFAULT_LANGUAGE.code}
>
<RadixDirectionProvider dir={currenLanguage.direction}>{children}</RadixDirectionProvider>
<DirectionProvider dir={currenLanguage.direction}>{children}</DirectionProvider>
</IntlProvider>
</TranslationsContext.Provider>
);
};
export { useLanguage };
export default I18nProvider;
@@ -1,5 +1,5 @@
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode, useState } from 'react';
import { useState, type ReactNode } from 'react';
import { handleQueryError } from '#infrastructure/reactQuery';
export const queryClient = new QueryClient({
@@ -1,7 +1,7 @@
'use client';
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { LocalStorageService } from '@crux-kit/local-storage';
import { APP_SETTINGS } from '#app/config/settings';
import { Settings } from '#app/config/types';
@@ -1,11 +1,9 @@
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import * as React from 'react';
import { type ComponentProps } from 'react';
export default function ThemeProvider({
children
}: React.ComponentProps<typeof NextThemesProvider>) {
export default function ThemeProvider({ children }: ComponentProps<typeof NextThemesProvider>) {
return (
<NextThemesProvider
attribute="class"
@@ -1,4 +1,4 @@
import { Navigate, RouteObject, useRoutes } from 'react-router';
import { Navigate, useRoutes, type RouteObject } from 'react-router';
import { MultipleMenusLayout } from '#app/layouts/06__MultipleMenus/layout';
import { getModuleRoutes } from '#app/services/modules';
import { AuthGuard } from '#core/Auth';
+12 -5
View File
@@ -1,5 +1,12 @@
export * from './menus';
export * from './messages';
export * from './registry';
export * from './routes';
export * from './types';
export { createModuleMenuConfig } from './menus';
export { collectModuleMessages } from './messages';
export { appModules } from './registry';
export { getModuleRoutes } from './routes';
export type { AppMessages } from './messages';
export type {
AppModuleArea,
AppModuleConfig,
AppModuleMessages,
AppModuleRouteScope
} from './types';
+1 -1
View File
@@ -1,4 +1,4 @@
import { IntlShape } from 'react-intl';
import { type IntlShape } from 'react-intl';
import { MenuConfig } from '#app/config/types';
import { appModules } from './registry';
@@ -1,4 +1,4 @@
import { LanguageCode } from '#app/i18n/types';
import { type LanguageCode } from '#app/i18n/types';
import { appModules } from './registry';
export type AppMessages = Record<LanguageCode, Record<string, string>>;
@@ -1,4 +1,4 @@
import { AppModuleConfig } from './types';
import { type AppModuleConfig } from './types';
const coreModules = import.meta.glob('#core/**/config/module.ts', {
eager: true,
@@ -1,5 +1,5 @@
import { appModules } from './registry';
import { AppModuleRouteScope } from './types';
import { type AppModuleRouteScope } from './types';
export const getModuleRoutes = (scope: AppModuleRouteScope) => {
return appModules
@@ -1,5 +1,5 @@
import { Users } from 'lucide-react';
import { IntlShape } from 'react-intl';
import { type IntlShape } from 'react-intl';
import { MenuItem } from '#app/config/types';
export const createAccessControlMenu = (intl: IntlShape): MenuItem => ({
@@ -1,4 +1,4 @@
import { AppModuleConfig } from '#app/services/modules/types';
import { type AppModuleConfig } from '#app/services/modules/types';
import { createAccessControlMenu } from './menu';
import { accessControlMessages } from './messages';
import { accessControlRoute } from './routing';
@@ -1,4 +1,4 @@
import { Navigate, RouteObject } from 'react-router-dom';
import { Navigate, type RouteObject } from 'react-router-dom';
import AdminListPage from '../pages/AdminListPage';
import RoleListPage from '../pages/RoleListPage';
import AdminManagementLayout from '../view/layouts/AdminManagementLayout';
@@ -1,35 +1,31 @@
import { axiosClient } from '#infrastructure/axios';
import { ListQueryParams, SuccessListResponse, SuccessMutationResponse } from '#infrastructure/reactQuery';
import { AdminDTO } from './dto';
const ROOT_API_ENDPOINT = '/admin';
import type { ListQueryParams, SuccessListResponse, SuccessMutationResponse } from '#infrastructure/reactQuery';
import type { AdminDTO } from './AccessControlDTO';
export default class AdminAPI {
getList = async (params: ListQueryParams) => {
return axiosClient
.get<SuccessListResponse<AdminDTO>>(ROOT_API_ENDPOINT, { params })
.get<SuccessListResponse<AdminDTO>>('/admin', { params })
.then((data) => data.data);
};
getDetailed = async (id: string) => {
return axiosClient.get<AdminDTO>(`${ROOT_API_ENDPOINT}/${id}`).then((data) => data.data);
return axiosClient.get<AdminDTO>(`/admin/${id}`).then((data) => data.data);
};
create = async (payload: Record<string, unknown>) => {
return axiosClient
.post<SuccessMutationResponse>(ROOT_API_ENDPOINT, payload)
.then((data) => data.data);
return axiosClient.post<SuccessMutationResponse>('/admin', payload).then((data) => data.data);
};
update = async (payload: { id: number } & Record<string, unknown>) => {
return axiosClient
.put<SuccessMutationResponse>(`${ROOT_API_ENDPOINT}/${payload.id}`, payload)
.put<SuccessMutationResponse>(`/admin/${payload.id}`, payload)
.then((data) => data.data);
};
delete = async (payload: { id: number }) => {
return axiosClient
.delete<SuccessMutationResponse>(`${ROOT_API_ENDPOINT}/${payload.id}`)
.delete<SuccessMutationResponse>(`/admin/${payload.id}`)
.then((data) => data.data);
};
}
@@ -1,10 +1,8 @@
import { axiosClient } from '#infrastructure/axios';
import { PermissionDTO } from './dto';
const ROOT_API_ENDPOINT = '/permission';
import type { PermissionDTO } from './AccessControlDTO';
export default class PermissionAPI {
getList = async () => {
return axiosClient.get<Array<PermissionDTO>>(ROOT_API_ENDPOINT).then((data) => data.data);
return axiosClient.get<Array<PermissionDTO>>('/permission').then((data) => data.data);
};
}
@@ -1,35 +1,31 @@
import { axiosClient } from '#infrastructure/axios';
import { ListQueryParams, SuccessListResponse, SuccessMutationResponse } from '#infrastructure/reactQuery';
import { RoleDTO } from './dto';
const ROOT_API_ENDPOINT = '/role';
import type { ListQueryParams, SuccessListResponse, SuccessMutationResponse } from '#infrastructure/reactQuery';
import type { RoleDTO } from './AccessControlDTO';
export default class RoleAPI {
getList = async (params: ListQueryParams) => {
return axiosClient
.get<SuccessListResponse<RoleDTO>>(ROOT_API_ENDPOINT, { params })
.get<SuccessListResponse<RoleDTO>>('/role', { params })
.then((data) => data.data);
};
getDetailed = async (id: string) => {
return axiosClient.get<RoleDTO>(`${ROOT_API_ENDPOINT}/${id}`).then((data) => data.data);
return axiosClient.get<RoleDTO>(`/role/${id}`).then((data) => data.data);
};
create = async (payload: { name: string; permissions: string[] }) => {
return axiosClient
.post<SuccessMutationResponse>(ROOT_API_ENDPOINT, payload)
.then((data) => data.data);
return axiosClient.post<SuccessMutationResponse>('/role', payload).then((data) => data.data);
};
update = async (payload: { id: number } & Record<string, unknown>) => {
return axiosClient
.put<SuccessMutationResponse>(`${ROOT_API_ENDPOINT}/${payload.id}`, payload)
.put<SuccessMutationResponse>(`/role/${payload.id}`, payload)
.then((data) => data.data);
};
delete = async (payload: { id: number }) => {
return axiosClient
.delete<SuccessMutationResponse>(`${ROOT_API_ENDPOINT}/${payload.id}`)
.delete<SuccessMutationResponse>(`/role/${payload.id}`)
.then((data) => data.data);
};
}
@@ -1,6 +1,6 @@
import { AdminEntity, AdminStatusEnum } from '../../entities/AdminEntity';
import { AdminDTO } from '../api/dto';
import RoleMapper from './RoleMapper';
import type { AdminDTO } from '#core/AccessControl/data/api/AccessControlDTO';
import RoleMapper from '#core/AccessControl/data/mappers/RoleMapper';
import type { AdminEntity, AdminStatusEnum } from '#core/AccessControl/entities/AdminEntity';
export default class AdminMapper {
private readonly roleMapper = new RoleMapper();
@@ -1,5 +1,5 @@
import { PermissionEntity } from '../../entities/PermissionEntity';
import { PermissionDTO } from '../api/dto';
import type { PermissionDTO } from '#core/AccessControl/data/api/AccessControlDTO';
import type { PermissionEntity } from '#core/AccessControl/entities/PermissionEntity';
export default class PermissionMapper {
public toEntity = (dto: PermissionDTO): PermissionEntity => {
@@ -1,6 +1,6 @@
import { RoleEntity } from '../../entities/RoleEntity';
import { RoleDTO } from '../api/dto';
import PermissionMapper from './PermissionMapper';
import type { RoleDTO } from '#core/AccessControl/data/api/AccessControlDTO';
import PermissionMapper from '#core/AccessControl/data/mappers/PermissionMapper';
import type { RoleEntity } from '#core/AccessControl/entities/RoleEntity';
export default class RoleMapper {
private readonly permissionMapper = new PermissionMapper();
@@ -1,10 +1,10 @@
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
import React from 'react';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { ErrorResponse, getPaginationInfo, ListQueryParams, SuccessMutationResponse } from '#infrastructure/reactQuery';
import AdminAPI from '../api/AdminAPI';
import AdminMapper from '../mappers/AdminMapper';
import { adminDetailedKeyBuilder, adminListKeyBuilder, DetailedPayload } from './keys';
import { getPaginationInfo, type ErrorResponse, type ListQueryParams, type SuccessMutationResponse } from '#infrastructure/reactQuery';
import AdminAPI from '#core/AccessControl/data/api/AdminAPI';
import AdminMapper from '#core/AccessControl/data/mappers/AdminMapper';
import { adminDetailedKeyBuilder, adminListKeyBuilder, type DetailedPayload } from './keys';
const api = new AdminAPI();
const mapper = new AdminMapper();
@@ -19,7 +19,7 @@ export const useGetList = (params: ListQueryParams = { page: 1, perPage: 10 }) =
}
});
const list = React.useMemo(() => {
const list = useMemo(() => {
return mapper.toEntities(data?.data ?? []);
}, [data?.data]);
@@ -1,4 +1,4 @@
import { createKeyBuilder, ListQueryParams } from '#infrastructure/reactQuery';
import { createKeyBuilder, type ListQueryParams } from '#infrastructure/reactQuery';
export interface DetailedPayload {
id: string;
@@ -1,6 +1,6 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import PermissionAPI from '../api/PermissionAPI';
import PermissionMapper from '../mappers/PermissionMapper';
import PermissionAPI from '#core/AccessControl/data/api/PermissionAPI';
import PermissionMapper from '#core/AccessControl/data/mappers/PermissionMapper';
import { permissionListKeyBuilder } from './keys';
const api = new PermissionAPI();
@@ -1,10 +1,10 @@
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
import React from 'react';
import { toast } from 'sonner';
import { ErrorResponse, getPaginationInfo, ListQueryParams, SuccessMutationResponse } from '#infrastructure/reactQuery';
import RoleAPI from '../api/RoleAPI';
import RoleMapper from '../mappers/RoleMapper';
import { DetailedPayload, roleDetailedKeyBuilder, roleListKeyBuilder } from './keys';
import { getPaginationInfo, type ErrorResponse, type ListQueryParams, type SuccessMutationResponse } from '#infrastructure/reactQuery';
import RoleAPI from '#core/AccessControl/data/api/RoleAPI';
import RoleMapper from '#core/AccessControl/data/mappers/RoleMapper';
import { roleDetailedKeyBuilder, roleListKeyBuilder, type DetailedPayload } from './keys';
const api = new RoleAPI();
const mapper = new RoleMapper();
@@ -1,5 +1,5 @@
import type { IsoDateString } from '@crux-kit/types';
import { RoleEntity } from './RoleEntity';
import type { RoleEntity } from './RoleEntity';
export enum AdminStatusEnum {
ACTIVE = 'active',
@@ -1,5 +1,5 @@
import type { IsoDateString } from '@crux-kit/types';
import { PermissionEntity } from './PermissionEntity';
import type { PermissionEntity } from './PermissionEntity';
export interface RoleEntity {
id: number;
@@ -1,7 +1,7 @@
import React from 'react';
import { AdminTable } from '#core/AccessControl/view/widgets/AdminTable';
import { PermissionPageGate } from '#core/Permission';
import { Container } from '#shared/widgets/common/Container';
import { AdminTable } from '../../view/widgets/AdminTable';
const AdminListPage: React.FC = () => {
return (
@@ -1,7 +1,7 @@
import React from 'react';
import { RoleTable } from '#core/AccessControl/view/widgets/RoleTable';
import { PermissionPageGate } from '#core/Permission';
import { Container } from '#shared/widgets/common/Container';
import { RoleTable } from '../../view/widgets/RoleTable';
const RoleListPage: React.FC = () => {
return (
@@ -9,7 +9,7 @@ import { FormElement, FormGrid, GridElement, InputCheckboxGroup, InputSelect, In
import { Button } from '#shared/ui/Button';
import { Form as FormProvider } from '#shared/ui/form';
import { Separator } from '#shared/ui/Separator';
import { ADMIN_FORM_ID, createValidationSchema, FormValues, SubmitFunc } from './form';
import { ADMIN_FORM_ID, createValidationSchema, type FormValues, type SubmitFunc } from './form';
export const AdminForm: React.FC<{
roles: Array<{ label: string; value: string }>;
@@ -1,8 +1,8 @@
import { UseFormReturn } from 'react-hook-form';
import { IntlShape } from 'react-intl';
import { type UseFormReturn } from 'react-hook-form';
import { type IntlShape } from 'react-intl';
import z from 'zod';
import { uid } from '@crux-kit/utils';
import { AdminEntity, AdminStatusEnum } from '#core/AccessControl/entities/AdminEntity';
import { AdminStatusEnum, type AdminEntity } from '#core/AccessControl/entities/AdminEntity';
export const ADMIN_FORM_ID = uid();
@@ -2,11 +2,12 @@ import { extractValidationErrors } from '#infrastructure/reactQuery';
import { useCreate, useUpdate } from '#core/AccessControl/data/queries/admin';
import { invalidateAdminDetailed, invalidateAdminList } from '#core/AccessControl/data/queries/cache';
import { useAuth } from '#core/Auth/providers/AuthProvider';
import { SubmitFunc } from './form';
import { type SubmitFunc } from './form';
interface Params {
onSuccess?: (id?: number) => void;
}
export const useSubmit = (params?: Params) => {
const onSuccess = params?.onSuccess;
const { verify } = useAuth();
@@ -5,7 +5,7 @@ import { useIntl } from 'react-intl';
import { FormElement, FormGrid, GridElement, InputText } from '#shared/forms/composed';
import { FormControl, FormField, FormItem, FormLabel, FormMessage, Form as FormProvider } from '#shared/ui/form';
import { Switch, SwitchWrapper } from '#shared/ui/Switch';
import { createValidationSchema, FormValues, ROLE_FORM_ID, SubmitFunc } from './form';
import { createValidationSchema, ROLE_FORM_ID, type FormValues, type SubmitFunc } from './form';
export const RoleForm: React.FC<{
permissions: Record<string, Array<{ label: string; value: string }>>;
@@ -2,7 +2,7 @@ import { UseFormReturn } from 'react-hook-form';
import { IntlShape } from 'react-intl';
import z from 'zod';
import { uid } from '@crux-kit/utils';
import { RoleEntity } from '#core/AccessControl/entities/RoleEntity';
import { type RoleEntity } from '#core/AccessControl/entities/RoleEntity';
export const ROLE_FORM_ID = uid();
@@ -1,10 +1,10 @@
import { ErrorOption, UseFormReturn } from 'react-hook-form';
import type { ErrorOption, UseFormReturn } from 'react-hook-form';
import { toast } from 'sonner';
import { ErrorResponse, extractValidationErrors } from '#infrastructure/reactQuery';
import { extractValidationErrors, type ErrorResponse } from '#infrastructure/reactQuery';
import { invalidateRoleDetailed, invalidateRoleList } from '#core/AccessControl/data/queries/cache';
import { useCreate, useUpdate } from '#core/AccessControl/data/queries/role';
import { useAuth } from '#core/Auth/providers/AuthProvider';
import { FormValues, SubmitFunc } from './form';
import type { FormValues, SubmitFunc } from './form';
interface Params {
onSuccess?: (id?: number) => void;
@@ -1,10 +1,10 @@
import React from 'react';
import { useIntl } from 'react-intl';
import { Outlet, useLocation } from 'react-router';
import { createAccessControlMenu } from '#core/AccessControl/config/menu';
import AccessControlLogo from '#core/AccessControl/view/ui/AccessControlLogo';
import { useMenu } from '#shared/hooks/useMenu';
import { Container } from '#shared/widgets/common/Container';
import { createAccessControlMenu } from '../../config/menu';
import AccessControlLogo from '../ui/AccessControlLogo';
const AdminManagementLayout: React.FC = () => {
const intl = useIntl();
@@ -1,9 +1,10 @@
import { Column, ColumnDef, ColumnFiltersState, getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, SortingState, useReactTable } from '@tanstack/react-table';
import { getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table';
import type { Column, ColumnDef, ColumnFiltersState, PaginationState, SortingState } from '@tanstack/react-table';
import { LoaderCircleIcon, SearchIcon, Settings2, SquarePen } from 'lucide-react';
import React from 'react';
import { IntlShape, useIntl } from 'react-intl';
import { useIntl, type IntlShape } from 'react-intl';
import { useDebounceSearch } from '@crux-kit/hooks';
import { IntlName } from '#infrastructure/IntlName';
import useIntlName from '#app/i18n/useIntlName';
import { mapTableStateToListQueryParams } from '#infrastructure/reactQuery';
import { PermissionGate } from '#core/Permission';
import { Badge } from '#shared/ui/Badge';
@@ -15,7 +16,7 @@ import { ScrollArea, ScrollBar } from '#shared/ui/ScrollArea';
import { Skeleton } from '#shared/ui/Skeleton';
import { DataGrid, DataGridColumnFilter, DataGridColumnHeader, DataGridColumnVisibility, DataGridPagination, DataGridTable, DataGridToolbarFilters, useDataGrid } from '#shared/widgets/DataGrid';
import { useGetList } from '../../data/queries/admin';
import { AdminEntity, AdminStatusEnum } from '../../entities/AdminEntity';
import { AdminStatusEnum, type AdminEntity } from '../../entities/AdminEntity';
import { DeleteAdminTrigger } from './DeleteAdminTrigger';
import { ManageAdminTrigger } from './ManageAdminTrigger';
@@ -36,6 +37,7 @@ const getStatusOptions = (intl: IntlShape) => [
export const AdminTable = () => {
const intl = useIntl();
const formatName = useIntlName();
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
@@ -180,7 +182,7 @@ export const AdminTable = () => {
cell: (info) => {
const data = info.row.original;
const id = data.id;
const name = IntlName.formatToDisplay(data, 'SURNAME_NAME');
const name = formatName(data, 'SURNAME_NAME');
const editModalTitle =
intl.formatMessage({ id: 'ACCESS_CONTROL.ADMIN.EDIT' }) + ' ' + name;
@@ -207,7 +209,7 @@ export const AdminTable = () => {
}
}
],
[intl, statusOptions]
[intl, statusOptions, formatName]
);
const table = useReactTable({
@@ -2,12 +2,12 @@ import { LoaderCircleIcon } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { useDisclosure } from '@crux-kit/hooks';
import { useGetDetailed } from '#core/AccessControl/data/queries/admin';
import { useGetList } from '#core/AccessControl/data/queries/role';
import { ADMIN_FORM_ID, AdminForm, initialValues, prepareInitialValues, useSubmit } from '#core/AccessControl/view/forms/AdminForm';
import { Button } from '#shared/ui/Button';
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '#shared/ui/Dialog';
import { Skeleton } from '#shared/ui/Skeleton';
import { useGetDetailed } from '../../data/queries/admin';
import { useGetList } from '../../data/queries/role';
import { ADMIN_FORM_ID, AdminForm, initialValues, prepareInitialValues, useSubmit } from '../forms/AdminForm';
export const ManageAdminTrigger: React.FC<
React.PropsWithChildren<{
@@ -2,12 +2,12 @@ import { LoaderCircleIcon } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { useDisclosure } from '@crux-kit/hooks';
import { useGetList } from '#core/AccessControl/data/queries/permission';
import { useGetDetailed } from '#core/AccessControl/data/queries/role';
import { initialValues, prepareInitialValues, ROLE_FORM_ID, RoleForm, useSubmit } from '#core/AccessControl/view/forms/RoleForm';
import { Button } from '#shared/ui/Button';
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '#shared/ui/Dialog';
import { Skeleton } from '#shared/ui/Skeleton';
import { useGetList } from '../../data/queries/permission';
import { useGetDetailed } from '../../data/queries/role';
import { initialValues, prepareInitialValues, ROLE_FORM_ID, RoleForm, useSubmit } from '../forms/RoleForm';
export const ManageRoleTrigger: React.FC<
React.PropsWithChildren<{
@@ -1,9 +1,14 @@
import { Column, ColumnDef, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, SortingState, useReactTable } from '@tanstack/react-table';
import { getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table';
import type { Column, ColumnDef, PaginationState, SortingState } from '@tanstack/react-table';
import { LoaderCircleIcon, SearchIcon, Settings2, SquarePen } from 'lucide-react';
import React from 'react';
import { useIntl } from 'react-intl';
import { useDebounceSearch } from '@crux-kit/hooks';
import { mapTableStateToListQueryParams } from '#infrastructure/reactQuery';
import { useGetList } from '#core/AccessControl/data/queries/role';
import { RoleEntity } from '#core/AccessControl/entities/RoleEntity';
import { DeleteRoleTrigger } from '#core/AccessControl/view/widgets/DeleteRoleTrigger';
import { ManageRoleTrigger } from '#core/AccessControl/view/widgets/ManageRoleTrigger';
import { PermissionGate } from '#core/Permission';
import { Button } from '#shared/ui/Button';
import { Card, CardFooter, CardHeader, CardTable, CardTitle, CardToolbar } from '#shared/ui/Card';
@@ -12,10 +17,6 @@ import { Input, InputWrapper } from '#shared/ui/Input';
import { ScrollArea, ScrollBar } from '#shared/ui/ScrollArea';
import { Skeleton } from '#shared/ui/Skeleton';
import { DataGrid, DataGridColumnHeader, DataGridColumnVisibility, DataGridPagination, DataGridTable, useDataGrid } from '#shared/widgets/DataGrid';
import { useGetList } from '../../data/queries/role';
import { RoleEntity } from '../../entities/RoleEntity';
import { DeleteRoleTrigger } from './DeleteRoleTrigger';
import { ManageRoleTrigger } from './ManageRoleTrigger';
interface IColumnFilterProps<TData, TValue> {
column: Column<TData, TValue>;
@@ -1,4 +1,4 @@
import { AppModuleConfig } from '#app/services/modules/types';
import type { AppModuleConfig } from '#app/services/modules/types';
import { accountSettingsMessages } from './messages';
import { accountSettingsRoute } from './routing';
@@ -1,6 +1,6 @@
import { Navigate, Route, RouteObject, Routes } from 'react-router-dom';
import ProfilePage from '../pages/Profile';
import AccountLayout from '../view/layouts/AccountLayout';
import { Navigate, Route, Routes, type RouteObject } from 'react-router-dom';
import ProfilePage from '#core/AccountSettings/pages/Profile';
import AccountLayout from '#core/AccountSettings/view/layouts/AccountLayout';
function Routing() {
return (
@@ -2,11 +2,11 @@ import React from 'react';
import { useIntl } from 'react-intl';
import { toast } from 'sonner';
import { Toolbar, ToolbarHeading } from '#app/layouts/01__Default/components/Toolbar';
import { ErrorResponse, extractValidationErrors, isValidationError } from '#infrastructure/reactQuery';
import { extractValidationErrors, isValidationError, type ErrorResponse } from '#infrastructure/reactQuery';
import { prepareFormValues, ProfileForm, SubmitFunc } from '#core/AccountSettings/view/forms/ProfileForm';
import { useAuth } from '#core/Auth/providers/AuthProvider';
import { Card, CardContent, CardHeader, CardTitle } from '#shared/ui/Card';
import { Container } from '#shared/widgets/common/Container';
import { prepareFormValues, ProfileForm, SubmitFunc } from '../../view/forms/ProfileForm';
const ProfilePage: React.FC = () => {
const intl = useIntl();
@@ -8,7 +8,7 @@ import { Button } from '#shared/ui/Button';
import { Form as FormProvider } from '#shared/ui/form';
import AvatarCropperField from './fields/AvatarCropperField';
import PasswordField from './fields/PasswordField';
import { createValidationSchema, FormValues, SubmitFunc } from './form';
import { createValidationSchema, type FormValues, type SubmitFunc } from './form';
export const ProfileForm: React.FC<{
initialValues: FormValues;
@@ -1,7 +1,7 @@
import { UseFormReturn } from 'react-hook-form';
import { IntlShape } from 'react-intl';
import type { UseFormReturn } from 'react-hook-form';
import type { IntlShape } from 'react-intl';
import z from 'zod';
import { AuthUser } from '#core/Auth/entities';
import type { AuthUser } from '#core/Auth/entities';
export const createValidationSchema = (intl: IntlShape) => {
return z
+1 -1
View File
@@ -1,4 +1,4 @@
import { AppModuleConfig } from '#app/services/modules/types';
import type { AppModuleConfig } from '#app/services/modules/types';
import { authMessages } from './messages';
import { authRoute } from './routing';
+3 -3
View File
@@ -1,6 +1,6 @@
import { Navigate, Route, RouteObject, Routes } from 'react-router-dom';
import { SignInPage } from '../pages/SignInPage';
import { BrandedLayout } from '../view/layouts/Branded';
import { Navigate, Route, Routes, type RouteObject } from 'react-router-dom';
import SignInPage from '#core/Auth/pages/SignInPage';
import BrandedLayout from '#core/Auth/view/layouts/Branded';
/**
* Handles all authentication related routes.
+2 -2
View File
@@ -1,6 +1,6 @@
import { axiosClient } from '#infrastructure/axios';
import { AuthModel } from '#core/Auth/entities';
import { GetUserResponse, UpdateUserPayload } from './dto';
import type { AuthModel } from '#core/Auth/entities';
import type { GetUserResponse, UpdateUserPayload } from './dto';
export const AuthApi = {
async login(email: string, password: string): Promise<AuthModel> {
+1 -1
View File
@@ -1,4 +1,4 @@
import { AuthUser } from '#core/Auth/entities';
import type { AuthUser } from '#core/Auth/entities';
export type UpdateUserPayload = Partial<AuthUser>;
@@ -1,5 +1,5 @@
import { AuthUser } from '../../entities';
import { GetUserResponse } from '../api/dto';
import type { GetUserResponse } from '#core/Auth/data/api/dto';
import type { AuthUser } from '#core/Auth/entities';
export default class UserMapper {
constructor() {}
@@ -1,6 +1,6 @@
import { LocalStorageService } from '@crux-kit/local-storage';
import { APP_VERSION } from '#app/config/app';
import { AuthModel } from '#core/Auth/entities';
import type { AuthModel } from '#core/Auth/entities';
const storage = new LocalStorageService({
prefix: `auth-v${APP_VERSION || '1.0'}`
+2 -2
View File
@@ -1,2 +1,2 @@
export * from './auth';
export * from './user';
export type { AuthModel } from './auth';
export type { AuthUser } from './user';
@@ -7,7 +7,7 @@ import { useAuth } from '../providers/AuthProvider';
import { SignInForm } from '../view/forms/SignInForm/Component';
import { SubmitFunc } from '../view/forms/SignInForm/form';
export function SignInPage() {
export default function SignInPage() {
const { login } = useAuth();
const intl = useIntl();
@@ -1,15 +1,15 @@
import { PropsWithChildren, useState } from 'react';
import { useState, type PropsWithChildren } from 'react';
import { queryClient } from '#app/providers/QueryProvider';
import { AuthApi } from '#core/Auth/data/api/api';
import { UpdateUserPayload } from '#core/Auth/data/api/dto';
import type { UpdateUserPayload } from '#core/Auth/data/api/dto';
import UserMapper from '#core/Auth/data/mappers/UserMapper';
import * as authHelper from '#core/Auth/data/stores/localStorage';
import { AuthModel, AuthUser } from '#core/Auth/entities';
import { getAuth, removeAuth, setAuth as setStoredAuth } from '#core/Auth/data/stores/localStorage';
import type { AuthModel, AuthUser } from '#core/Auth/entities';
import { AuthContext } from './context';
export function AuthProvider({ children }: PropsWithChildren) {
const [loading, setLoading] = useState(true);
const [auth, setAuth] = useState<AuthModel | undefined>(authHelper.getAuth());
const [auth, setAuth] = useState<AuthModel | undefined>(getAuth());
const [currentUser, setCurrentUser] = useState<AuthUser | undefined>();
const verify = async () => {
@@ -29,9 +29,9 @@ export function AuthProvider({ children }: PropsWithChildren) {
const saveAuth = (auth: AuthModel | undefined) => {
setAuth(auth);
if (auth) {
authHelper.setAuth(auth);
setStoredAuth(auth);
} else {
authHelper.removeAuth();
removeAuth();
}
};
@@ -1,6 +1,6 @@
import { createContext, useContext } from 'react';
import { GetUserResponse, UpdateUserPayload } from '#core/Auth/data/api/dto';
import { AuthModel, AuthUser } from '#core/Auth/entities';
import type { GetUserResponse, UpdateUserPayload } from '#core/Auth/data/api/dto';
import type { AuthModel, AuthUser } from '#core/Auth/entities';
export const AuthContext = createContext<{
loading: boolean;
@@ -5,7 +5,7 @@ import { useIntl } from 'react-intl';
import { FormElement, FormGrid, GridElement, InputText } from '#shared/forms/composed';
import { Button } from '#shared/ui/Button';
import { Form } from '#shared/ui/form';
import { createValidationSchema, FormValues, SubmitFunc } from './form';
import { createValidationSchema, type FormValues, type SubmitFunc } from './form';
export const SignInForm: React.FC<{
initialValues: FormValues;
@@ -1,4 +1,4 @@
import { IntlShape } from 'react-intl';
import type { IntlShape } from 'react-intl';
import z from 'zod';
export const createValidationSchema = (intl: IntlShape) =>
@@ -6,7 +6,7 @@ import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Badge } from '#shared/ui/Badge';
import { Card, CardContent } from '#shared/ui/Card';
export function BrandedLayout() {
export default function BrandedLayout() {
const intl = useIntl();
return (
@@ -3,7 +3,7 @@ import { Link, Outlet } from 'react-router-dom';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Card, CardContent } from '#shared/ui/Card';
export function ClassicLayout() {
export default function ClassicLayout() {
return (
<React.Fragment>
<style>
@@ -26,7 +26,7 @@ export function ClassicLayout() {
/>
</Link>
</div>
<Card className="w-full max-w-[25rem]">
<Card className="w-full max-w-100">
<CardContent className="p-6">
<Outlet />
</CardContent>
@@ -1,4 +1,4 @@
import { AppModuleConfig } from '#app/services/modules/types';
import type { AppModuleConfig } from '#app/services/modules/types';
import { fallbackScreensMessages } from './messages';
import { fallbackScreensRoute } from './routing';
@@ -1,5 +1,5 @@
import { FolderOpen, Wrench } from 'lucide-react';
import { IntlShape } from 'react-intl';
import type { IntlShape } from 'react-intl';
import { MenuItem } from '#app/config/types';
export const createFileManagerMenu = (intl: IntlShape): MenuItem => ({
@@ -1,4 +1,4 @@
import { AppModuleConfig } from '#app/services/modules/types';
import type { AppModuleConfig } from '#app/services/modules/types';
import { createFileManagerMenu } from './menu';
import { fileManagerMessages } from './messages';
import { fileManagerRoute } from './routing';
+10 -10
View File
@@ -1,7 +1,7 @@
import { AxiosProgressEvent, GenericAbortSignal } from 'axios';
import type { AxiosProgressEvent, GenericAbortSignal } from 'axios';
import { axiosClient } from '#infrastructure/axios';
import { ensureLeadingSlash } from '#core/FileManager/shared/path/leadingSlash';
import * as DTO from './dto';
import type { CreateDirectoryResponseDTO, DuplicateFileEntriesResponseDTO, GetEntriesResponseDTO, GetFileResponseDTO, RemoveEntryResponseDTO, RenameEntryResponseDTO, SearchEntriesResponseDTO, UploadFileResponseDTO } from './dto';
export class Api {
getEntries = async (
@@ -15,7 +15,7 @@ export class Api {
}
) => {
return await axiosClient
.get<DTO.GetEntriesResponseDTO>('/file-manager' + ensureLeadingSlash(path), {
.get<GetEntriesResponseDTO>('/file-manager' + ensureLeadingSlash(path), {
params: payload
})
.then((data) => data.data);
@@ -23,7 +23,7 @@ export class Api {
getFile = async (path: string) => {
return await axiosClient
.get<DTO.GetFileResponseDTO>('/file-manager/files/details' + ensureLeadingSlash(path))
.get<GetFileResponseDTO>('/file-manager/files/details' + ensureLeadingSlash(path))
.then((data) => data.data);
};
@@ -37,19 +37,19 @@ export class Api {
createDirectory = async (payload: { path: string; name: string }) => {
return await axiosClient
.post<DTO.CreateDirectoryResponseDTO>('/file-manager/folders', payload)
.post<CreateDirectoryResponseDTO>('/file-manager/folders', payload)
.then((data) => data.data);
};
renameEntry = async (payload: { path: string; name: string }) => {
return await axiosClient
.put<DTO.RenameEntryResponseDTO>('/file-manager/entries/rename', payload)
.put<RenameEntryResponseDTO>('/file-manager/entries/rename', payload)
.then((data) => data.data);
};
removeEntry = async (path: string) => {
return await axiosClient
.delete<DTO.RemoveEntryResponseDTO>('/file-manager' + ensureLeadingSlash(path))
.delete<RemoveEntryResponseDTO>('/file-manager' + ensureLeadingSlash(path))
.then((data) => data.data);
};
@@ -61,7 +61,7 @@ export class Api {
}
) => {
return await axiosClient
.post<DTO.UploadFileResponseDTO>('/file-manager/files', formData, {
.post<UploadFileResponseDTO>('/file-manager/files', formData, {
signal: params.signal,
headers: {
'Content-Type': 'multipart/form-data',
@@ -74,13 +74,13 @@ export class Api {
duplicateFiles = async (paths: string[]) => {
return await axiosClient
.post<DTO.DuplicateFileEntriesResponseDTO>('/file-manager/files/duplicate', { paths })
.post<DuplicateFileEntriesResponseDTO>('/file-manager/files/duplicate', { paths })
.then((data) => data.data);
};
searchEntries = async (query: string) => {
return await axiosClient
.get<DTO.SearchEntriesResponseDTO>('/file-manager/search', {
.get<SearchEntriesResponseDTO>('/file-manager/search', {
params: { q: query }
})
.then((data) => data.data);
@@ -3,7 +3,7 @@ import { runInAction } from 'mobx';
import React from 'react';
import EntryMapper from '#core/FileManager/data/mappers/EntryMapper';
import { fetchEntries, useGetEntries } from '#core/FileManager/data/queries/entries';
import { entriesKeyBuilder, EntriesQueryPayload } from '#core/FileManager/data/queries/keys';
import { entriesKeyBuilder, type EntriesQueryPayload } from '#core/FileManager/data/queries/keys';
import EntriesStore from '#core/FileManager/data/stores/entriesStore';
import ExplorerStore from '#core/FileManager/data/stores/explorerStore';
import { getSettingsData } from '#core/FileManager/data/stores/localStorage';
@@ -1,6 +1,6 @@
import { Entry, EntryType, FileEntry, FolderEntry } from '#core/FileManager/entities/entry';
import { EntryType, type Entry, type FileEntry, type FolderEntry } from '#core/FileManager/entities/entry';
import { ensureLeadingSlash } from '#core/FileManager/shared/path/leadingSlash';
import { EntryDTO } from '../api/dto';
import { type EntryDTO } from '../api/dto';
export default class EntryMapper {
constructor(private readonly parent: string) {}

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